Jenkins 路由解析及沙箱绕过漏洞分析报告(上)
Sep 15, 2020
1 minute read

Jenkins 路由解析及沙箱绕过漏洞分析报告(上)

简介

本报告主要研究Jenkins的路由解析机制和Groovy沙箱绕过带来的安全问题,梳理Jenkins官方2018-2019年以来涉及沙箱绕过的安全更新,探讨Java沙箱在Java应用中的安全性。由于篇幅较长,分为上下两篇发表,文中疏漏之处还请批评指正。

Jenkins是一个开源软件项目,是基于Java开发的一种持续集成工具,用于监控持续重复的工作,旨在提供一个开放易用的软件平台,使软件的持续集成变成可能。Jenkins的目的是持续、自动化地构建/测试软件项目以及监控软件开发流程,快速问题定位及处理,提升开发效率。

Script Security插件是Jenkins的一个安全插件,可以集成到Jenkins各种功能插件中。它主要支持两个相关系统:脚本批准和Groovy沙箱,分别用来管控脚本是否允许执行以及将脚本限制在安全环境下执行,避免带来不可控风险。

环境搭建

  1. 下载相应版本的war包

    地址:https://updates.jenkins-ci.org/download/war/

  2. 设置环境变量JENKINS_HOME

    set JENKINS_HOME=D:\Jenkins\jenkins_2.137

  3. 加上调试选项并运行

    java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 -jar jenkins_2.137.war --httpPort=8082

  4. 安装插件

    国内镜像地址:https://mirrors.tuna.tsinghua.edu.cn/jenkins/updates/update-center.json

    Jenkins在安装过程中会自动下载部分插件的最新版,这部分可以先跳过,再在后台上传特定版本的插件(.hpi文件)进行安装,然后重启Jenkins完成安装

动态路由机制

首先从WEB-INF/web.xml入手看看Jenkins如何处理路由,可以看到所有请求都交给org.kohsuke.stapler.Stapler,具体是由Stapler:service()方法来处理

1568171738754

1568171941279

在service方法中主要调用的是invoke方法,两处调用的区别是invoke的第3和第4个参数不同,分别是根节点root和url路径,在调用之前判断了url路径,如果是/$stapler/bound/开头,则把根节点设置为boundObjectTable,否则通过this.webApp.getApp()把根节点设置为hudson.model.Hudson

跟进invoke方法

1568172501683

1568172552611

调用的是Stapler#tryInvoke()方法,tryInvoke()方法中对node类型(也就是一开始的root)进行了判断,按先后顺序分别处理三种情况

  • StaplerProxy
  • StaplerOverridable
  • StaplerFallback

这三种情况的具体区别可以参考Jenkins关于路由请求的文档

这里我们关注中间获取metaClass和调用dispatch的过程

1568172929817

通过传入/securityRealm/user/admin/动态调试来跟踪理解

初始化metaClass

WebApp中会缓存一个classMap存放MetaClass,无对应缓存则通过

mc = new MetaClass(this, c); 

进行初始化,这个过程发生在Jenkins刚启动没有缓存时,当建立缓存后则直接从classMap获取相应的MetaClass

MetaClass mc = (MetaClass)this.classMap.get(c);

1568188301839

在MetaClass的构造方法中,会再次调用其父类的getMetaClass()方法,直到父类为空为止

1568188747361

而此时的kclass为一开始传入的hudson.model.Hudson,Hudson继承关系如下图

1568193610000

因此getMetaClass一直调用到class java.lang.Object,然后进行buildDispatchers()方法并层层返回,因此整个初始化metaClass的过程是一个不断寻找继承树并递归调用buildDispatchers的过程,而buildDispatchers的功能就是提取该类中的所有函数信息存储在MetaClass.dispatchers中,作为后续与url的映射关系

buildDispatchers()方法按顺序调用如下:

  • this.registerDoToken(node)
    • do(…)
  • node.methods.prefix(“js”).iterator()
    • js(…)
  • node.methods.annotated(JavaScriptMethod.class).iterator()
    • @JavaScriptMethod annotation
  • node.methods.prefix(“get”)
    • get()
    • get(String)
    • get(Int)
    • get(Long)
  • getMethods.signature(new Class[]{StaplerRequest.class}).iterator()
    • get(StaplerRequest)
  • getMethods.signatureStartsWith(new Class[]{String.class}).name(“getDynamic”).iterator()
    • getDynamic(…)
  • node.methods.name(“doDynamic”).iterator()
    • doDynamic(…)

这相当于规定了一个函数命名规则,只要符合这个规则的方法都能被访问到。

注意此过程中,大部分dispatchers添加的都是NameBasedDispatcher对象,除了如下几类:

  • DirectoryishDispatcher (url路径相关,如/?../等)
  • HttpDeletableDispatcher (DELETE方法)
  • IndexDispatcher (doIndex(...))
  • Dispatcher (getDynamic(…) doDynamic(…))

其中js<token>(…)对应的JavaScriptProxyMethodDispatcher继承自NameBasedDispatcher

1568196821681

hudson.model.Hudson经过递归buildDispatchers,缓存下的dispatchers有220个,根据上面的注意点,其中大部分方法会调用到NameBasedDispatcher#dispatch()

1568196931745

1568195521387

递归解析路由

回到org.kohsuke.stapler.Stapler#tryInvoke(),路径/securityRealm/对应的是hudson.security.SecurityRealm jenkins.model.Jenkins.getSecurityRealm(),同样也会调用到NameBasedDispatcher#dispatch()

1568197833515

接下来可以看到ff.invoke()返回一个hudson.security.HudsonPrivateSecurityRealm对象,然后重新调用org.kohsuke.stapler.Stapler#invoke(),这也是一个递归的过程。此时HudsonPrivateSecurityRealm返回的dispatchers有30个,在Stapler#tryInvoke()中进行循环调用,在每个dispatchers动态生成的dispatch方法中,会根据解析到的url路径与当前的dispatchers进行对比,不一致直接返回false,同时还会判断是否存在下一层路由,如果存在则进入doDispatch

比如此时解析到的url为/user/,则只有hudson.security.HudsonPrivateSecurityRealm.getUser(String)方法进入下一步doDispatch

1568254883924

当传入一个不存在的url,tryInvoke会返回false,抛出404,也就不继续往下解析了

1568255416381

经过上面递归的tryInvoke过程,Jenkins才完成路由解析,调用过程的流程图如下

1568256409501

动态路由绕过

这是一个动态路由绕过导致未授权访问的问题,由Orange提交:)参考 Hacking Jenkins Part 1 - Play with Dynamic Routing

白名单机制

上面分析了Jenkins构建动态路由的过程,主要调用的是org.kohsuke.stapler.Stapler#tryInvoke()方法,该方法对所属于StaplerProxy的类会有一次权限检查,而一开始我们知道除了boundObjectTable其他的node都被设置为hudson.model.Hudson,上面也讲到Hudson类继承自Jenkins,而Jenkins的父类AbstractCIBaseStaplerProxy的一个接口实现,所以除了boundObjectTable外所有node都会进行这个权限检查,具体实现在jenkins.model.Jenkins#getTarget()

1568259600209

这个方法会先进行一次checkPermission,如果没有权限则会抛出异常还会再进行一次isSubjectToMandatoryReadPermissionCheck检查,如果这个检查通过同样会正常返回

1568259863106

这个检查中有一个白名单,如果存在于这个白名单中的url路由同样可以直接访问

1568273111696

ALWAYS_READABLE_PATHS = ImmutableSet.of("/login", "/logout", "/accessDenied", "/adjuncts/", "/error", "/oops", new String[] {
    "/signup",
    "/tcpSlaveAgentListener",
    "/federatedLoginService/",
    "/securityRealm",
    "/instance-identity"
});

还是以/securityRealm/user/admin/为例,在解析至securityRealm的时候命中白名单,正常返回,而解析至admin的时候因为User类并非StaplerProxy子类,所以会跳过getTarget()检查,成功绕过

跨物件操作

接下来关注DescriptorByName

从继承关系图可以看到User也是DescriptorByNameOwner接口的实现

1571821898844

DescriptorByNameOwner接口调用的是 jenkins.model.Jenkins#getDescriptor

public interface DescriptorByNameOwner extends ModelObject {
    default Descriptor getDescriptorByName(String id) {
        return Jenkins.getInstance().getDescriptorByName(id);
    }
}

1571822175710

该方法首先获取了所有的descriptors,如果传入的id匹配到了相应的descriptor就能去调用指定的方法,例如org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript#DescriptorImplgetDisplayName()doCheckScript()都是能被调用到的

1571823142780

因此,通过构造/securityRealm/user/DescriptorByName/xxx的方式就可以调用到任意类的任意方法,只要满足下面两个条件:

  1. 符合上文整理的命名规则;
  2. 目标类继承了Descriptor

利用链:

Jenkins->HudsonPrivateSecurityRealm->User->DescriptorByNameOwner->Jenkins->Descriptor

在这个漏洞修复后还想再利用则必须开启Allow anonymous read access匿名用户访问权限,否则会抛出404

总结

本报告上篇讨论了Jenkins动态路由机制和路由绕过的问题,通过这个脆弱点可以绕过用户权限检查从而访问到特定的物件,为下一步进行远程代码执行漏洞攻击降低了攻击门槛,是一个非常巧妙的入口。下篇将分析Jenkins主流插件Script Security中针对Groovy沙箱的绕过方法,欢迎关注。

参考


Back to posts


comments powered by Disqus