该文实际发于06/06/2021,之前未完全公开具体细节。

不讲武德

11月9日晚上,有个朋友问我,马老师发生什么事了,给我发了来几张截图。我一看,噢,原来是前几天,saltstack公布了安全更新,一个CVE-2020-16846,一个CVE-2020-25592,这两个一起用能未授权RCE。。

Abstract

在对CVE-2020-17490和CVE-2020-16846进行分析后,发现CVE-2020-17490的补丁存在未修补完全的情况,导致wheel_async仍然存在未授权访问,可以调用wheel模块中的方法,基于此对SaltStack的wheel模块中的方法进行分析,最终发现加载配置模块存在模板注入,可以实现远程代码执行。

不再赘述,CVE-2020-17490、CVE-2020-16846,分析文章在这里

Background

SaltStack是VMware子公司,其产品用于运维管理,能够支持数万台服务器,主要功能是配置文件管理和远程执行命令,十分易用且强大,在github有11.4k star,阿里云的Nacos与之类似,不过Nacos管理的是微服务。

SaltStack只用python开发,采用C/S架构,其中Server被称为Master,Client被称为Minion,即一个Master能够向多个Minion下发配置文件,远程执行命令。SlatStack是系统总称,主要有salt、salt-master、salt-minion、salt-api等程序组成,其中salt-master和salt-minion的功能为从指定路径读取配置文件并启动。salt-master监听4505和4506端口,分别用于发布消息和接受监控数据。

salt程序可以调用大量函数,并可以指定minion或指定一组minion作为目标。salt-api可以使用cherrypy或tornado来对外提供REST接口,默认使用cherrypy。

本文主要对salt-master和salt-api展开讨论。

文中指定代码位置采用以下约定:FileLocation:Classname.method()或FileLocation:Method()

梅开二度

通过分析CVE-2020-25592的补丁可以发现 ,补丁通过调用认证模块对SSH方法进行权限认证,而salt/salt/netapi/init.py:NetapiClient.run()方法通过getattr动态调用NetapiClient类中的方法,并将args和kwargs作为参数传入。

该类中可调用的方法有

1
2
3
4
5
6
7
8
9
- local
- local_async
- local_batch
- local_subset
- runner
- runner_async
- ssh
- wheel
- wheel_async

经过分析,其中,wheel_async方法存在未授权调用,其他方法(除去SSH)均为生成一个job到zeromq,其后进行消费者再进行认证,而wheel_async异步调用wheel包中的方法。

调用链如下:

salt/salt/netapi/init.py:NetapiClient.run() ⇒ salt/salt/netapi/init.py:NetapiClient.wheel_async() ⇒ salt/salt/wheel/init.py:WheelClient.cmd_async() ⇒ salt/salt/client/mixins.py:AsyncClientMixin.asynchronous()

salt/salt/client/mixins.py:AsyncClientMixin.asynchronous()

这里的目标函数是self._proc_function,low参数为POST可控参数,fun参数的值在salt/salt/wheel/init.py:WheelClient.cmd_async()方法中通过low参数的fun键获取。

这里通过salt/salt/client/mixins.py:AsyncClientMixin._proc_function()函数调用salt/salt/client/mixins.py:SyncClientMixin.low(),并通过该函数使用args参数和kwargs参数动态调用wheel包中的方法。

salt/salt/client/mixins.py:SyncClientMixin.low()

可调用的方法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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

其中salt/salt/wheel/pillar_roots.py:write()方法存在任意写入文件漏洞,不过需要__opts__[“pillar_roots”]中的路径存在,salt/salt/wheel/config.py:update_config()方法可以向master配置的文件夹中写入配置文件。

这里的读文件是没有办法利用的,由于是异步调用,所以返回的是jid和tag,通过jid和tag去查询任务执行的结果时是有认证的。

salt/salt/wheel/pillar_roots.py:write()

salt/salt/wheel/config.py:update_config()

我大E了啊,没有闪

当我发现可以向master配置文件夹中写任意配置文件时,理所当然的认为应该已经可以RCE了。在将salt-master开启debug模式时,发现每60s会重新加载配置文件,所以我以为master的配置项都是每60s自动刷新的,同时读入的配置文件会刷新到__opts__变量中,该变量相当于salt的环境变量,当__opst__可控时,逻辑上的灵活性非常大。

故当时的思路为将__opts__[“pillar_roots”]修改为一个存在的路径,这样可以通过os.path.isdir()检查,从而使用”../../“进行路径穿越,无限制任意写文件。具体操作如下:

1
2
3
4
5
6
7
8
9
10
POST /run HTTP/1.1
Host: 10.211.55.14:8000
User-Agent: python-requests/2.25.0
Accept-Encoding: gzip, deflate
Accept: */*
Connection: close
Content-Length: 213
Content-Type: application/json

{"token": "12312", "client": "wheel_async", "tgt": "*", "fun": "config.update_config", "arg": [], "kwarg": {"file_name": "b", "yaml_contents": {"pillar_roots":{"base": ["/srv/pillar", "/srv/spm/pillar","/tmp"]}}}}

当写入pillar_roots配置后发现并没有加载,故思路转变为写入不安全配置配置使攻击者成为一个高权限的minion,从而达到一些操作,比如auto_accept和open_mode。

在写入确认配置后,黑盒进行测试发现配置项并未生效,需要salt-master重启才能够生效,故思路变为找一个DOS漏洞,发送一些畸形数据或触发一些未捕获的error使其重启。

3002版本的salt-api在遇到某一个未处理的error时会自动重启,比如在SSH模块中调用系统的ssh-keygen程序,当ssh-keygen程序不存在时,salt-api就会自动重启。

由于没有挖掘DOS漏洞的经验,故在经过数个小时的尝试和搜寻后,决定放弃此路,转变思路为分析一下salt-master的自动加载逻辑,尝试从自动加载逻辑中挖掘一些可利用的点。

耗子尾汁,好好反思

通过—log-level=debug参数开启debug模式,定位到了master自动加载的逻辑。

salt/salt/master.py:Maintenance.run()

从代码中可以看出,每一个self.loop_interval将循环一次,loop_interval在配置文件中可以配置,默认为60s。通过debug发现在salt.daemons.masterapi.clean_old_jobs中读取minion配置文件。

调用栈如下:

salt/salt/daemons/masterapi.py:clean_old_jobs() ⇒ salt/salt/minion.py:MasterMinion.init() ⇒ salt/salt/config/init.py:minion_config()

在 salt/salt/minion.py:MasterMinion.init()中发现,自动加载值加载grains相关的参数,grains为saltstack收取各个minion中系统信息的功能。

salt/salt/minion.py:MasterMinion.init()

salt/salt/config/init.py:minion_config()

可以看到minio在加载配置文件的时候调用了一个很诱人的方法apply_sdb(),这个方法解析配置中以sdb://开头的字符串。

salt/salt/config/init.py:apply_sdb()

salt/salt/utils/sdb.py:sdb_get()

在这个函数中sdb://aaaa/bbbb字符串,saltstack将会在配置文件中找aaaa这个配置项,并读取其中driver字段,赋值给fun变量,经bbbb赋值给query参数。最后的salt.loader.sdb(opts, fun, utils=utils)是一个动态调用,通过LazyLoader加载fun变量值对应的方法,并调用,其中LazyLoader将加载salt.sdb包下的所有文件,并调用其中的get方法。

经过查找,最终定位到salt/salt/sdb/rest.py文件。

salt/salt/sdb/rest.py:query()

在这里,key为上述字符串中bbbb的值,可以看到这里还接收形如bbbb?ccc=ddd的参数,并且通过**key_vars传递到compile_template方法中。

这里的render使用的是jinja,众所周知,jinja是可以进行模板注入的,也就是说,在模板可控的情况下,如果不存在过滤,将可以执行任意代码,并且这里传入的参数是profile[key][‘url’],也就是配置文件中aaaa配置项中bbbb字典url的值。compile_template函数详情如下:

salt/salt/template.py:compile_template()

这里的render调用的是salt/salt/renderers/jinja.py中的render方法,调用链如下:

salt/salt/template.py:compile_template() ⇒ salt/salt/utils/templates.py:JINJA() ⇒ salt/salt/utils/templates.py:wrap_tmpl_func() ⇒ salt/salt/utils/templates.py:render_jinja_tmpl()

最后调用到render_jinja_tmpl中的template.render()方法,在此处渲染模板,此中并未对传入的参数进行过滤,可以进行模板注入。

但自动加载的逻辑中加载的minion的配置文件,故回到最初的salt/salt/wheel中进行翻找,发现salt/salt/wheel/minions.py:connected()方法调用了master_config方法,master_config和minion_config一样,都调用了apply_sdb()方法。

salt/salt/wheel/minions.py:connected()

总结一下:

  1. salt-api可以未授权调用wheel_async
  2. wheel_async可以调用update_config方法,写入master的配置文件。
  3. SlatStack在加载master配置文件的时候动态加载sdb链接,并利用jinja渲染模版。
  4. 调用connected方法,让SaltStack去加载写入的配置文件,完成任意代码/命令执行。

POC

修复措施

  1. 尽快更新官方补丁。
  2. 如果没有用到wheel_async模块,可以在salt/salt/netapi/init.py中将其删除。
⬆︎TOP