k1n9's Blog

Jenkins Stapler动态路由任意方法调用

影响版本: <= 2.137

插件: Pipeline: Declarative Plugin up to and including 1.3.4
Pipeline: Groovy Plugin up to and including 2.61
Script Security Plugin up to and including 1.49

Groovy Plugin up to and including 2.0
Warnings Plugin up to and including 5.0.0

CVE-2018-1000861 流程调试分析:

从 web.xml 看可知 Jenkins 的请求都是先交给 org.kohsuke.stapler.Stapler 类进行处理,其 service 方法大抵如下

可以看到它会根据 url 来给 invoke 方法传入不同的根节点对象,如果 url 以 /$stapler/bound/ 开头,根节点对象为 org.kohsuke.stapler.bind.BoundObjectTable,否则为 hudson.model.Hudson(继承自 jenkins.model.Jenkins)。往前跟,到进入 org.kohsuke.stapler.Stapler#tryInvoke

根节点对象 hudson.model.Hudson 属于 StaplerProxy 类的实例,所以执行会进入到 if 的语句块。这里的 getTarget 来自 jenkins.model.Jenkins#getTarget

能看到有对读权限的检测,如果在未登录 Jenkins 的情况下是以匿名者的身份去访问的,默认情况下匿名者没有任何的权限。这里在捕获到异常的后还会拿 url 来做一次检测,如果符合条件的话,依然能成功返回。跟进 isSubjectToMandatoryReadPermissionCheck 方法

总的来说有三种情况是可以在没有读权限的情况下继续进行访问的(这里为后面构造攻击利用提供了一个无需读权限的点),这里只看第一个

只要 url 符合上图列出的这些就可以,这里用到的是 securityRealm

继续执行 tryInvoke 方法的后面部分

获取到根节点对象的所有 Dispatcher(其具体的生成过程往 getMetaClass 方法里跟就能看到),然后逐个传入当前的请求进行调用。根据监控窗口可以看出来这里的 Dispatcher 有 218 个,Dispatcher 是根据根节点对象中成员变量或方法等生成的,其对应着不同的 url。url 对应着的调用关系可以参考 Stapler 的文档,这里简单列下其中几种:

1. Action 方法
/fooBar/ => node.doFooBar()

2. Public 成员
/fooBar/ => node.fooBar

3. Public Getter 方法
/fooBar/ => node.getFooBar()

4. Public Getter 方法带一个字符串或整数参数
/fooBar/test/ => node.getFooBar(test)

进入 org.kohsuke.stapler.NameBasedDispatcher#dispatch

可以看到这里是用 url 跟 Dispatcher 的名称进行了对比,然后再拿 url 剩余的层级数跟参数个数进行了比较,如果符合条件的话就调用 doDispatch 方法,不然就继续前面的循环。这里的 doDispatch 方法是在生成 Dispatcher 的时候就建立了的,往下跟

这里的 ff 是 org.kohsuke.stapler.Function 类对象,其中保存了根节点对象中方法的各种信息,当调用其 invoke 方法的时候便通过反射来对实际的方法进行调用,返回的结果作为新的根节点对象再进入 org.kohsuke.stapler.Stapler#invoke 执行跟前面一样的流程,一直做递归调用直到把 url 全部解析完,这样便实现了动态路由解析。针对其它的 Dispatcher 的处理流程跟这个也差不多。 到这里其实把 Stapler 的动态路由功能实现简单的过了一遍,如果从安全的角度来考虑就明白这里是存在着任意方法调用的问题的,即使前面看到了有对读权限的检测,但也有不需要读权限的地方,如果把这些点串起来就能够形成一个完整的利用链。

RCE 的利用链

Orange 大佬在博客中提到的部分利用链

/securityRealm/user/[username]/descriptorByName/[descriptor_name]/

在有前面的知识基础下,这个利用链还是很好理解的,实际调用过程如下:

Jenkisn.getSecurityRealm() -> HudsonPrivateSecurityRealm.getUser([username]) -> Jenkins.getDescriptorByName([descriptor_name])

这里要提一下的是 User 类在 2.138 (2018/8/14 发布)版后做了修改,添加了对接口 StaplerProxy 的实现,然后重写 getTarget 方法,其中加了对读权限的检测。在前面知道如果是属于 StaplerProxy 类的实例在 org.kohsuke.stapler.Stapler#tryInvoke 中是会调用到其 getTarget 方法的。所以这个利用链在 2.138 版后就用不了了。

getDescriptorByName 最终调用的是 jenkins.model.Jenkins#getDescriptor

先获取所有的 Descriptor,然后用完整的类名(带包名)或类名跟传入的字符串 id 做比较来获取相对应的 Descriptor。再通过寻找一些有利用点的 Descriptor 便可以构造出完整的利用链。

在 Jenkins 2019-01-08 和 2019-01-28 的安全通告中都包含有 Groovy 沙盒绕过的问题。拿 Groovy Plugin 做例子来看一下其在 Github 上的 diff

这里的 doCheckScript 方法会将传进来的 command 参数的值当作 Groovy 脚本进行解析

类似的点还有,都可以在 Jenkins 的 Github 上通过看 commit 找到。但这里存在一个问题是它只调用了 parse 方法来解析而没有去执行脚本,那这里究竟是怎么做到的 RCE ?

其实看这几个插件在 Github 的 diff 或新加的测试代码,就能想到问题肯定是出在 ASTTest 和 Grab 这两注解上面,但具体的原因还得跟过去看才行。

调试跟踪 GroovyShell 的 parse 方法

顺着 parse 往下跟,直到 groovy.lang.GroovyClassLoader#doParseClass 方法,从 parse 方法到这里的过程都比较容易理解就不贴出来了

从这里开始简单分析下 Groovy 做解析的过程(纯粹是通过看到的代码的个人理解,可能会有错误)。doParseClass 方法的前面部分就是从 codeSource 中把 Groovy 脚本取出来然后加入到新构建好的编译单元中去等一些操作。这里要说的是在执行 unit.complie 方法的时候传入的 goalPhase 参数,这个 goalPhase 代表了编译的时候要执行到哪个一个阶段,Groovy 做编译一共有 9 个阶段

  1. 初始化
  2. 词法分析,生成 CST(具体语法树)
  3. CST 转换成 AST(抽象语法树)
  4. AST 语义分析
  5. AST 分析完成
  6. 类生成(阶段 1,指令部分)
  7. 类生成完成
  8. 输出字节码到硬盘
  9. 最后阶段,做清理操作

这样就好理解为什么在配置 config 中存在目标路径的时候会设置成 8 了,这里设置的值为 7,所以只会完成到类生成部分。跟进去

大体上看就是处理每个阶段所有做的操作了,值得注意的是整个过程中它在每个阶段都会去检测 progressCallback 是否为空,如果不为空就调用它的 call 方法。

一直跟到第 4 阶段,也就是做 AST 语义分析的时候,看它是如何处理 ASTTest 注解的。顺着 processPhaseOperations 往下跟就能看到完整的过程,过程还挺长的,这里就不列了,代码里面用到了访问者设计模式。直接跟到 org.codehaus.groovy.transform.ASTTransformationVisitor#visitClass

跟进去就能看到能做 RCE 的点了,org.codehaus.groovy.transform.ASTTestTransformation#visit

这不仅是创建了 progressCallback,还在 call 方法里执行了 Groovy 脚本,脚本的来源参数 testSource 的值还是可控的。

因为是在第 4 阶段就设置了 progressCallback,所以到第 7 阶段结束的时候,这方法一共会执行 4 次。

Groovy Plugin 2.0 Payload(其它的点也是类似的操作)

GET /jenkins/securityRealm/user/test/descriptorByName/StringScriptSource/checkScript?command=import%20groovy.transform.%2a%[email protected]%28value%3D%7B%20%22open%20/applications/calculator.app%22.execute%28%29%20%7D%29%[email protected]%20int%20x%0A HTTP/1.1
Host: hack.lo:8080
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.109 Safari/537.36
DNT: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,fr;q=0.7,zh-TW;q=0.6,da;q=0.5,mt;q=0.4
Cookie: 
Connection: close
GoTop