Skip to content

Commit 3d1425d

Browse files
Merge pull request #665 from mr-mosi:Erlang_OTP_SSH_RCE
PiperOrigin-RevId: 809146022 Change-Id: Ie27fde22b2d99380cb2bcb7e13608527e559ecbe
2 parents 0ef2f53 + 479bf53 commit 3d1425d

9 files changed

Lines changed: 816 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Erlang/OTP SSH Server CVE-2025-32433 RCE Detector
2+
3+
This detector checks for Remote Code Execution (RCE) vulnerability in Erlang/OTP
4+
SSH servers (CVE-2025-32433). A command injection flaw in the SSH subsystem
5+
allows unauthenticated attackers to execute arbitrary commands by sending
6+
crafted SSH messages to affected Erlang/OTP versions.
7+
8+
## References
9+
10+
- https://github.com/erlang/otp/security/advisories/GHSA-37cp-fgq5-7wc2
11+
- https://nvd.nist.gov/vuln/detail/CVE-2025-32433
12+
13+
## Build jar file for this plugin
14+
15+
Using `gradlew`:
16+
17+
```shell
18+
./gradlew jar
19+
```
20+
21+
Tsunami identifiable jar file is located at `build/libs` directory.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
plugins {
2+
id 'java-library'
3+
}
4+
5+
description = 'Apache RocketMQ CVE-2023-33246 Tsunami detector plugin.'
6+
group = 'com.google.tsunami'
7+
version = '0.0.1-SNAPSHOT'
8+
9+
repositories {
10+
maven { // The google mirror is less flaky than mavenCentral()
11+
url 'https://maven-central.storage-download.googleapis.com/repos/central/data/'
12+
}
13+
mavenCentral()
14+
mavenLocal()
15+
}
16+
17+
def coreRepoBranch = System.getenv("GITBRANCH_TSUNAMI_CORE") ?: "stable"
18+
def tcsRepoBranch = System.getenv("GITBRANCH_TSUNAMI_TCS") ?: "stable"
19+
20+
dependencies {
21+
implementation("com.google.tsunami:tsunami-common") {
22+
version { branch = "${coreRepoBranch}" }
23+
}
24+
implementation("com.google.tsunami:tsunami-plugin") {
25+
version { branch = "${coreRepoBranch}" }
26+
}
27+
implementation("com.google.tsunami:tsunami-proto") {
28+
version { branch = "${coreRepoBranch}" }
29+
}
30+
31+
testImplementation "junit:junit:4.13.2"
32+
testImplementation "org.mockito:mockito-core:5.18.0"
33+
testImplementation "com.google.truth:truth:1.4.4"
34+
testImplementation "com.squareup.okhttp3:mockwebserver:3.12.0"
35+
testImplementation "com.google.truth.extensions:truth-java8-extension:1.4.4"
36+
testImplementation "com.google.truth.extensions:truth-proto-extension:1.4.4"
37+
testImplementation "com.google.inject:guice:6.0.0"
38+
testImplementation "com.google.inject.extensions:guice-testlib:6.0.0"
39+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
rootProject.name = 'erlangotp-cve-2025-32433'
2+
3+
def coreRepository = System.getenv("GITREPO_TSUNAMI_CORE") ?: "https://github.com/google/tsunami-security-scanner.git"
4+
def tcsRepository = System.getenv("GITREPO_TSUNAMI_TCS") ?: "https://github.com/google/tsunami-security-scanner-callback-server.git"
5+
6+
sourceControl {
7+
gitRepository("${coreRepository}") {
8+
producesModule("com.google.tsunami:tsunami-common")
9+
producesModule("com.google.tsunami:tsunami-plugin")
10+
producesModule("com.google.tsunami:tsunami-proto")
11+
}
12+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.tsunami.plugins.detectors.rce.cve202532433;
18+
19+
import static java.lang.annotation.ElementType.FIELD;
20+
import static java.lang.annotation.ElementType.METHOD;
21+
import static java.lang.annotation.ElementType.PARAMETER;
22+
23+
import java.lang.annotation.Retention;
24+
import java.lang.annotation.RetentionPolicy;
25+
import java.lang.annotation.Target;
26+
import javax.inject.Qualifier;
27+
28+
/** Annotation for {@link ErlangOtpSshCve2025324336Detector}. */
29+
final class Annotations {
30+
@Qualifier
31+
@Retention(RetentionPolicy.RUNTIME)
32+
@Target({PARAMETER, METHOD, FIELD})
33+
@interface OobSleepDuration {}
34+
35+
@Qualifier
36+
@Retention(RetentionPolicy.RUNTIME)
37+
@Target({PARAMETER, METHOD, FIELD})
38+
@interface SocketFactoryInstance {}
39+
40+
private Annotations() {}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.tsunami.plugins.detectors.rce.cve202532433;
18+
19+
import static com.google.common.base.Preconditions.checkNotNull;
20+
import static com.google.common.collect.ImmutableList.toImmutableList;
21+
import static com.google.tsunami.plugins.detectors.rce.cve202532433.SshClient.connectAndExecuteCommand;
22+
23+
import com.google.common.collect.ImmutableList;
24+
import com.google.common.flogger.GoogleLogger;
25+
import com.google.common.util.concurrent.Uninterruptibles;
26+
import com.google.protobuf.util.Timestamps;
27+
import com.google.tsunami.common.time.UtcClock;
28+
import com.google.tsunami.plugin.PluginType;
29+
import com.google.tsunami.plugin.VulnDetector;
30+
import com.google.tsunami.plugin.annotations.PluginInfo;
31+
import com.google.tsunami.plugin.payload.NotImplementedException;
32+
import com.google.tsunami.plugin.payload.Payload;
33+
import com.google.tsunami.plugin.payload.PayloadGenerator;
34+
import com.google.tsunami.plugins.detectors.rce.cve202532433.Annotations.OobSleepDuration;
35+
import com.google.tsunami.plugins.detectors.rce.cve202532433.Annotations.SocketFactoryInstance;
36+
import com.google.tsunami.proto.DetectionReport;
37+
import com.google.tsunami.proto.DetectionReportList;
38+
import com.google.tsunami.proto.DetectionStatus;
39+
import com.google.tsunami.proto.NetworkService;
40+
import com.google.tsunami.proto.PayloadGeneratorConfig;
41+
import com.google.tsunami.proto.Severity;
42+
import com.google.tsunami.proto.TargetInfo;
43+
import com.google.tsunami.proto.TransportProtocol;
44+
import com.google.tsunami.proto.Vulnerability;
45+
import com.google.tsunami.proto.VulnerabilityId;
46+
import java.io.IOException;
47+
import java.io.InputStream;
48+
import java.io.OutputStream;
49+
import java.time.Clock;
50+
import java.time.Duration;
51+
import java.time.Instant;
52+
import javax.inject.Inject;
53+
import javax.net.SocketFactory;
54+
55+
/** A Tsunami plugin that detects Erlang/OTP SSH RCE vulnerability CVE-2025-32433. */
56+
@PluginInfo(
57+
type = PluginType.VULN_DETECTION,
58+
name = "ErlangOtpSshCve2025324336Detector",
59+
version = "0.1",
60+
description = "This plugin detects the Erlang/OTP SSH CVE-2025-32433 RCE vulnerability.",
61+
author = "mr-mosi",
62+
bootstrapModule = ErlangOtpSshCve2025324336DetectorBootstrapModule.class)
63+
public class ErlangOtpSshCve2025324336Detector implements VulnDetector {
64+
private static final GoogleLogger logger = GoogleLogger.forEnclosingClass();
65+
66+
private final Clock utcClock;
67+
private final PayloadGenerator payloadGenerator;
68+
private final SocketFactory socketFactory;
69+
private final int oobSleepDuration;
70+
71+
@Inject
72+
ErlangOtpSshCve2025324336Detector(
73+
@UtcClock Clock utcClock,
74+
PayloadGenerator payloadGenerator,
75+
@SocketFactoryInstance SocketFactory socketFactory,
76+
@OobSleepDuration int oobSleepDuration) {
77+
this.utcClock = checkNotNull(utcClock);
78+
this.payloadGenerator = checkNotNull(payloadGenerator);
79+
this.socketFactory = checkNotNull(socketFactory);
80+
this.oobSleepDuration = oobSleepDuration;
81+
}
82+
83+
@Override
84+
public ImmutableList<Vulnerability> getAdvisories() {
85+
return ImmutableList.of(
86+
Vulnerability.newBuilder()
87+
.setMainId(
88+
VulnerabilityId.newBuilder()
89+
.setPublisher("TSUNAMI_COMMUNITY")
90+
.setValue("CVE-2025-32433"))
91+
.setSeverity(Severity.CRITICAL)
92+
.setTitle("Erlang/OTP SSH Remote Code Execution Vulnerability (CVE-2025-32433)")
93+
.setDescription(
94+
"Erlang/OTP before OTP-27.3.3, OTP-26.2.5.11, and OTP-25.3.2.20 contains a command"
95+
+ " injection vulnerability in the SSH subsystem. An unauthenticated attacker"
96+
+ " can exploit this flaw by sending a crafted SSH message, leading to remote"
97+
+ " code execution on the affected system.")
98+
.setRecommendation(
99+
"Upgrade Erlang/OTP to version OTP-27.3.3, OTP-26.2.5.11, and OTP-25.3.2.20 or"
100+
+ " later to address CVE-2025-32433. Additionally, restrict or remove SSH"
101+
+ " exposure to untrusted networks to reduce risk.")
102+
.addRelatedId(
103+
VulnerabilityId.newBuilder().setPublisher("CVE").setValue("CVE-2025-32433"))
104+
.build());
105+
}
106+
107+
@Override
108+
public DetectionReportList detect(
109+
TargetInfo targetInfo, ImmutableList<NetworkService> matchedServices) {
110+
logger.atInfo().log("Starting detection of Erlang/OTP SSH CVE-2025-32433.");
111+
112+
return DetectionReportList.newBuilder()
113+
.addAllDetectionReports(
114+
matchedServices.stream()
115+
.filter(this::isErlangOtpSshService)
116+
.filter(this::isServiceVulnerable)
117+
.map(service -> buildDetectionReport(targetInfo, service))
118+
.collect(toImmutableList()))
119+
.build();
120+
}
121+
122+
private boolean isErlangOtpSshService(NetworkService service) {
123+
logger.atInfo().log("Checking if the service is a Erlang/OTP SSH service.");
124+
if (!(service.getServiceName().equalsIgnoreCase("ssh")
125+
&& service.getTransportProtocol() == TransportProtocol.TCP)) {
126+
logger.atInfo().log("Service is not SSH");
127+
return false;
128+
}
129+
var serviceIp = service.getNetworkEndpoint().getIpAddress().getAddress();
130+
var servicePort = service.getNetworkEndpoint().getPort().getPortNumber();
131+
try (var socket = socketFactory.createSocket(serviceIp, servicePort)) {
132+
// Connecting to SSH server...
133+
socket.setSoTimeout(5000);
134+
OutputStream out = socket.getOutputStream();
135+
InputStream in = socket.getInputStream();
136+
// Banner exchange
137+
out.write("SSH-2.0-OpenSSH_8.9\r\n".getBytes());
138+
out.flush();
139+
byte[] bannerBuffer = new byte[1024];
140+
int bytesRead = in.read(bannerBuffer);
141+
if (bytesRead > 0) {
142+
String banner = new String(bannerBuffer, 0, bytesRead).trim();
143+
if (!banner.startsWith("SSH-2.0-Erlang/")) {
144+
logger.atWarning().log(
145+
"Service at %s:%s is not an Erlang SSH server.", serviceIp, servicePort);
146+
socket.close();
147+
return false;
148+
}
149+
socket.close();
150+
return true;
151+
}
152+
} catch (IOException e) {
153+
logger.atWarning().withCause(e).log(
154+
"Failed to send payload to service at %s:%s.", serviceIp, servicePort);
155+
}
156+
return true;
157+
}
158+
159+
private boolean isServiceVulnerable(NetworkService service) {
160+
logger.atInfo().log("Checking if Erlang/OTP SSH service is vulnerable.");
161+
162+
var payload = getTsunamiCallbackHttpPayload();
163+
// Check the callback server is enabled
164+
if (payload == null || !payload.getPayloadAttributes().getUsesCallbackServer()) {
165+
logger.atWarning().log(
166+
"The Tsunami callback server is not setup for this environment, so we cannot confirm the"
167+
+ " RCE callback");
168+
return false;
169+
}
170+
String command = payload.getPayload();
171+
172+
var serviceIp = service.getNetworkEndpoint().getIpAddress().getAddress();
173+
var servicePort = service.getNetworkEndpoint().getPort().getPortNumber();
174+
try (var socket = socketFactory.createSocket(serviceIp, servicePort)) {
175+
if (!connectAndExecuteCommand(socket, command, logger)) {
176+
return false;
177+
}
178+
Uninterruptibles.sleepUninterruptibly(Duration.ofSeconds(oobSleepDuration));
179+
return payload.checkIfExecuted();
180+
} catch (IOException e) {
181+
logger.atWarning().withCause(e).log(
182+
"Failed to send payload to service at %s:%s.", serviceIp, servicePort);
183+
return false;
184+
}
185+
}
186+
187+
private Payload getTsunamiCallbackHttpPayload() {
188+
try {
189+
return this.payloadGenerator.generate(
190+
PayloadGeneratorConfig.newBuilder()
191+
.setVulnerabilityType(PayloadGeneratorConfig.VulnerabilityType.BLIND_RCE)
192+
.setInterpretationEnvironment(
193+
PayloadGeneratorConfig.InterpretationEnvironment.LINUX_SHELL)
194+
.setExecutionEnvironment(
195+
PayloadGeneratorConfig.ExecutionEnvironment.EXEC_INTERPRETATION_ENVIRONMENT)
196+
.build());
197+
} catch (NotImplementedException n) {
198+
return null;
199+
}
200+
}
201+
202+
private DetectionReport buildDetectionReport(TargetInfo targetInfo, NetworkService service) {
203+
return DetectionReport.newBuilder()
204+
.setTargetInfo(targetInfo)
205+
.setNetworkService(service)
206+
.setDetectionTimestamp(Timestamps.fromMillis(Instant.now(utcClock).toEpochMilli()))
207+
.setDetectionStatus(DetectionStatus.VULNERABILITY_VERIFIED)
208+
.setVulnerability(this.getAdvisories().getFirst())
209+
.build();
210+
}
211+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.tsunami.plugins.detectors.rce.cve202532433;
18+
19+
import com.google.inject.Provides;
20+
import com.google.tsunami.plugin.PluginBootstrapModule;
21+
import com.google.tsunami.plugins.detectors.rce.cve202532433.Annotations.OobSleepDuration;
22+
import com.google.tsunami.plugins.detectors.rce.cve202532433.Annotations.SocketFactoryInstance;
23+
import javax.net.SocketFactory;
24+
25+
/** A module for bootstrapping the {@link ErlangOtpSshCve2025324336Detector}. */
26+
public final class ErlangOtpSshCve2025324336DetectorBootstrapModule extends PluginBootstrapModule {
27+
28+
@Override
29+
protected void configurePlugin() {
30+
registerPlugin(ErlangOtpSshCve2025324336Detector.class);
31+
}
32+
33+
@Provides
34+
@OobSleepDuration
35+
int provideOobSleepDuration(ErlangOtpSshCve2025324336DetectorConfig configs) {
36+
if (configs.oobSleepDuration == 0) {
37+
return 2;
38+
}
39+
return configs.oobSleepDuration;
40+
}
41+
42+
@Provides
43+
@SocketFactoryInstance
44+
SocketFactory provideSocketFactoryInstance() {
45+
return SocketFactory.getDefault();
46+
}
47+
}

0 commit comments

Comments
 (0)