Skip to content

Commit

Permalink
Add a detector for exposed NodeRED instances as they allow for very t…
Browse files Browse the repository at this point in the history
…rivial command execution. This plugin was tested against version `v3.1.5`.

PiperOrigin-RevId: 611493248
Change-Id: Ic4a4d1e79d662df0c7a15017f4c682ece1a6e8cb
  • Loading branch information
tooryx authored and copybara-github committed Feb 29, 2024
1 parent ee9f5af commit 5ec8083
Show file tree
Hide file tree
Showing 6 changed files with 470 additions and 0 deletions.
14 changes: 14 additions & 0 deletions google/detectors/exposedui/nodered/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# NodeRED unprotected instance

This detector checks whether a NodeRED instance is available without
authentication (which allows very easy RCE).

## Build jar file for this plugin

Using `gradlew`:

```shell
./gradlew jar
```

Tsunami identifiable jar file is located at `build/libs` directory.
83 changes: 83 additions & 0 deletions google/detectors/exposedui/nodered/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
plugins {
id 'java-library'
}

description = 'Tsunami VulnDetector plugin for exposed NodeRED.'
group = 'com.google.tsunami'
version = '0.0.1-SNAPSHOT'

repositories {
maven { // The google mirror is less flaky than mavenCentral()
url 'https://maven-central.storage-download.googleapis.com/repos/central/data/'
}
mavenCentral()
mavenLocal()
}

java {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11

jar.manifest {
attributes('Implementation-Title': name,
'Implementation-Version': version,
'Built-By': System.getProperty('user.name'),
'Built-JDK': System.getProperty('java.version'),
'Source-Compatibility': sourceCompatibility,
'Target-Compatibility': targetCompatibility)
}

javadoc.options {
encoding = 'UTF-8'
use = true
links 'https://docs.oracle.com/javase/8/docs/api/'
source = '8'
}

// Log stacktrace to console when test fails.
test {
testLogging {
exceptionFormat = 'full'
showExceptions true
showCauses true
showStackTraces true
}
maxHeapSize = '1500m'
}
}

ext {
floggerVersion = '0.5.1'
guavaVersion = '28.2-jre'
javaxInjectVersion = '1'
jsoupVersion = '1.9.2'
okhttpVersion = '3.12.0'
protobufVersion = '3.11.4'
tsunamiVersion = 'latest.release'

junitVersion = '4.13'
mockitoVersion = '2.28.2'
truthVersion = '1.0.1'
}

dependencies {
implementation "com.google.flogger:flogger:${floggerVersion}"
implementation "com.google.flogger:google-extensions:${floggerVersion}"
implementation "com.google.flogger:flogger-system-backend:${floggerVersion}"
implementation "com.google.guava:guava:${guavaVersion}"
implementation "com.google.protobuf:protobuf-java:${protobufVersion}"
implementation "com.google.protobuf:protobuf-javalite:${protobufVersion}"
implementation "com.google.protobuf:protobuf-java-util:${protobufVersion}"
implementation "com.google.tsunami:tsunami-common:${tsunamiVersion}"
implementation "com.google.tsunami:tsunami-plugin:${tsunamiVersion}"
implementation "com.google.tsunami:tsunami-proto:${tsunamiVersion}"
implementation "javax.inject:javax.inject:${javaxInjectVersion}"
implementation "org.jsoup:jsoup:${jsoupVersion}"

testImplementation "com.google.truth:truth:${truthVersion}"
testImplementation "com.google.truth.extensions:truth-java8-extension:${truthVersion}"
testImplementation "com.google.truth.extensions:truth-proto-extension:${truthVersion}"
testImplementation "com.squareup.okhttp3:mockwebserver:${okhttpVersion}"
testImplementation "junit:junit:${junitVersion}"
testImplementation "org.mockito:mockito-core:${mockitoVersion}"
}
1 change: 1 addition & 0 deletions google/detectors/exposedui/nodered/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = 'exposed_nodered'
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.tsunami.plugins.detectors.exposedui.nodered;

import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.tsunami.common.net.http.HttpRequest.get;

import com.google.common.collect.ImmutableList;
import com.google.common.flogger.GoogleLogger;
import com.google.protobuf.util.Timestamps;
import com.google.tsunami.common.data.NetworkServiceUtils;
import com.google.tsunami.common.net.http.HttpClient;
import com.google.tsunami.common.net.http.HttpResponse;
import com.google.tsunami.common.time.UtcClock;
import com.google.tsunami.plugin.PluginType;
import com.google.tsunami.plugin.VulnDetector;
import com.google.tsunami.plugin.annotations.PluginInfo;
import com.google.tsunami.proto.DetectionReport;
import com.google.tsunami.proto.DetectionReportList;
import com.google.tsunami.proto.DetectionStatus;
import com.google.tsunami.proto.NetworkService;
import com.google.tsunami.proto.Severity;
import com.google.tsunami.proto.TargetInfo;
import com.google.tsunami.proto.Vulnerability;
import com.google.tsunami.proto.VulnerabilityId;
import java.io.IOException;
import java.time.Clock;
import java.time.Instant;
import javax.inject.Inject;

/** A {@link VulnDetector} that detects exposed NodeRED instances. */
@PluginInfo(
type = PluginType.VULN_DETECTION,
name = "NodeRedExposedUiDetector",
version = "0.1",
description = "Detects exposed NodeRED instances",
author = "Pierre Precourt ([email protected])",
bootstrapModule = NodeRedExposedUiDetectorBootstrapModule.class)
public final class NodeRedExposedUiDetector implements VulnDetector {
private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();

private final Clock utcClock;
private final HttpClient httpClient;

@Inject
NodeRedExposedUiDetector(@UtcClock Clock utcClock, HttpClient httpClient) {
this.utcClock = checkNotNull(utcClock);
this.httpClient = checkNotNull(httpClient).modify().build();
}

@Override
public DetectionReportList detect(
TargetInfo targetInfo, ImmutableList<NetworkService> matchedServices) {
logger.atInfo().log("Starting detection: exposed NodeRED instances");
DetectionReportList detectionReports =
DetectionReportList.newBuilder()
.addAllDetectionReports(
matchedServices.stream()
.filter(NetworkServiceUtils::isWebService)
.filter(this::isServiceVulnerable)
.map(networkService -> buildDetectionReport(targetInfo, networkService))
.collect(toImmutableList()))
.build();

logger.atInfo().log(
"NodeRedExposedUiDetector finished, detected '%d' vulns.",
detectionReports.getDetectionReportsCount());
return detectionReports;
}

/*
* Checks if the settings are accessible. The /settings page will either return a JSON content or
* a permission denied error depending on the configuration for authentication.
* Because /settings can be a pretty common endpoint, we want to ensure that this is a rednode
* instance whilst not really performing JSON parsing hence the pattern matching instead.
*/
private boolean settingsAreAccessible(NetworkService networkService) {
String targetUri = NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + "settings";

try {
HttpResponse response = httpClient.send(get(targetUri).withEmptyHeaders().build());

return response.status().isSuccess()
&& response
.bodyString()
.map(
body ->
body.contains("\"httpNodeRoot\"")
&& body.contains("\"version\"")
&& body.contains("\"workflow\""))
.orElse(false);
} catch (IOException e) {
logger.atWarning().withCause(e).log("Unable to query '%s'.", targetUri);
return false;
}
}

private boolean isNodeRedInstance(NetworkService networkService) {
String targetUri =
NetworkServiceUtils.buildWebApplicationRootUrl(networkService) + "/red/tours/welcome.js";

try {
HttpResponse response = httpClient.send(get(targetUri).withEmptyHeaders().build());

return response.status().isSuccess()
&& response.bodyString().map(body -> body.contains("Welcome to Node-RED")).orElse(false);
} catch (IOException e) {
logger.atWarning().withCause(e).log("Unable to query '%s'.", targetUri);
return false;
}
}

private boolean isServiceVulnerable(NetworkService networkService) {
return isNodeRedInstance(networkService) && settingsAreAccessible(networkService);
}

private DetectionReport buildDetectionReport(
TargetInfo scannedTarget, NetworkService vulnerableNetworkService) {
return DetectionReport.newBuilder()
.setTargetInfo(scannedTarget)
.setNetworkService(vulnerableNetworkService)
.setDetectionTimestamp(Timestamps.fromMillis(Instant.now(utcClock).toEpochMilli()))
.setDetectionStatus(DetectionStatus.VULNERABILITY_VERIFIED)
.setVulnerability(
Vulnerability.newBuilder()
.setMainId(
VulnerabilityId.newBuilder()
.setPublisher("GOOGLE")
.setValue("NODERED_EXPOSED_UI"))
.setSeverity(Severity.CRITICAL)
.setTitle("Exposed NodeRED instance")
.setRecommendation(
"Configure authentication or ensure the NodeRED instance is not exposed to the"
+ " network. See"
+ " https://nodered.org/docs/user-guide/runtime/securing-node-red for"
+ " details")
.setDescription(
"NodeRED instance is exposed and can be used to compromise the system."))
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/*
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.tsunami.plugins.detectors.exposedui.nodered;

import com.google.tsunami.plugin.PluginBootstrapModule;

/** A {@link PluginBootstrapModule} for {@link NodeRedExposedUiDetector}. */
public final class NodeRedExposedUiDetectorBootstrapModule extends PluginBootstrapModule {

@Override
protected void configurePlugin() {
registerPlugin(NodeRedExposedUiDetector.class);
}
}
Loading

0 comments on commit 5ec8083

Please sign in to comment.