CVE-2026-20912

Gitea Attachment Smuggling: The Private-to-Public Pipeline

Amit Schendel
Amit Schendel
Senior Security Researcher

Jan 24, 2026·7 min read·4 visits

Executive Summary (TL;DR)

Gitea failed to verify that an attachment belongs to the repository where it is being linked. An attacker with access to a private repository can take the UUID of a sensitive attachment and 'adopt' it into a public release or issue in a different repository. This exposes private files (binaries, configs, datasets) to the public internet. Fixed in version 1.25.4.

A critical logic flaw in Gitea's attachment handling allows authenticated users to link files from private repositories to public releases, effectively bypassing access controls and exposing sensitive data to the internet.

The Hook: When Is a File Not Your File?

Gitea is the darling of the self-hosted Git world. It's written in Go, it's lightweight, and it generally does a good job of keeping the doors locked. But like many modern web applications, it suffers from a common architectural headache: the disconnect between an object's existence and its context. In the world of database-driven applications, we often assign unique identifiers (UUIDs) to everything—users, repos, issues, and, crucially for this story, attachments.

Attachments in Gitea are those binary blobs you drag and drop into an issue comment or a release page. They aren't Git objects; they live in a separate storage backend (local disk, S3, etc.) and are referenced by a database entry. When you upload a file, Gitea gives it a UUID and stamps it with a RepoID to remember where it came from. So far, so good.

But here is the million-dollar question: When you tell Gitea, "Hey, attach file UUID-1234 to Release v1.0 in Repository B," does Gitea check if UUID-1234 actually belongs to Repository B? Or does it just check if UUID-1234 exists? If you guessed the latter, congratulations. You've just found a Critical vulnerability. This is a classic logic flaw where the application trusts the ID provided by the user without validating the relationship between that ID and the current context.

The Flaw: IDOR with a Twist

Technically, this falls under the umbrella of Insecure Direct Object Reference (IDOR), or specifically CWE-639 (Authorization Bypass Through User-Controlled Key). The flaw wasn't in the authentication layer—you still needed to be logged in. The flaw was in the authorization context of the attachment handler.

The vulnerability stems from how Gitea handles the "Edit Release" or "Edit Issue" actions. When you edit a release, the frontend sends a list of attachment UUIDs that should be associated with that release. The backend iterates through this list to update the database.

The logic went something like this:

  1. User sends request to update Release X in Repo A.
  2. User includes attachment UUID-Z in the payload.
  3. Server looks up UUID-Z. Does it exist? Yes.
  4. Server links UUID-Z to Release X.

The missing step? Checking if UUID-Z belongs to Repo A.

This oversight allows for what I call "Attachment Smuggling." If I have read access to a private repository (Repo P), I can see the UUIDs of its attachments. If I also have write access to a public repository (Repo Public)—which is trivial since I can just create one—I can take the UUID from the private repo and tell Gitea to attach it to my public release. The database updates, the link is created, and suddenly that private tax document or proprietary binary is being served publicly from my innocuous-looking repo.

The Code: The Missing 'If' Statement

Let's look at the smoking gun. The vulnerability lived in routers/web/repo/attachment.go. The code was responsible for handling attachment updates but was too trusting of the input.

The Vulnerable Logic (Conceptual):

// OLD CODE (Vulnerable)
func UpdateReleaseAttachments(ctx *context.Context) {
    // Get the attachment ID from the request
    attachID := ctx.FormString("attachment_uuid")
    
    // Load the attachment from DB
    attach, err := models.GetAttachmentByUUID(attachID)
    if err != nil {
        // Handle error
    }
    
    // DANGER: We proceed to link the attachment without checking ownership!
    attach.ReleaseID = ctx.Release.ID
    models.UpdateAttachment(attach)
}

The developers fixed this in Pull Request #36320 by adding a strict ownership check. They realized that just because an attachment exists doesn't mean the current context has the right to move it.

The Fix (Patched):

// NEW CODE (Fixed in 1.25.4)
func UpdateReleaseAttachments(ctx *context.Context) {
    // ... load attachment ...
 
    // THE FIX: Ensure the attachment belongs to the current repository
    if attach.RepoID != ctx.Repo.Repository.ID {
        ctx.HTTPError(http.StatusBadRequest, "attachment does not belong to this repository")
        return
    }
    
    // ... proceed ...
}

This simple condition attach.RepoID != ctx.Repo.Repository.ID completely kills the attack class. It enforces a boundary: objects created in Repo A stay in Repo A. It effectively segregates the object references by their parent container.

The Exploit: Smuggling Secrets

To exploit this, an attacker needs two things: read access to a victim's private repo (perhaps as a contractor or low-level employee) and write access to any public repo.

Step 1: Reconnaissance The attacker navigates to the target private repository, for example, Company/TopSecretProject. They find a release or an issue containing a sensitive attachment, say credentials.json or proprietary_firmware.bin. By inspecting the network traffic (Developer Tools > Network) or the HTML source, they locate the attachment's UUID. It looks something like a1b2c3d4-e5f6-7890-1234-567890abcdef.

Step 2: The Setup The attacker creates a new public repository, Attacker/Free-Wallpapers. They create a new Release titled "v1.0".

Step 3: The Swap The attacker intercepts the HTTP request used to create or edit the release in their public repo. The payload will contain a field for attachments. They inject the stolen UUID from Step 1:

POST /Attacker/Free-Wallpapers/releases/new
{
  "tag_name": "v1.0",
  "title": "Cool Wallpaper",
  "files": [
    "a1b2c3d4-e5f6-7890-1234-567890abcdef" // <--- The UUID from the PRIVATE repo
  ]
}

Step 4: The Payload The server processes the request. It sees the UUID, grabs the file pointer from the storage backend, and links it to the Attacker/Free-Wallpapers release. Now, anyone on the internet can visit the attacker's public release page and download the proprietary_firmware.bin directly, bypassing the authentication checks that should have protected the original private repository.

The Impact: Why This Matters

This vulnerability is rated Critical (9.1) for a reason. It completely undermines the "Private" setting on a repository. In a corporate environment, Gitea is often used to host internal tools, configuration files with embedded secrets, or source code that isn't meant for the public eye.

Consider a scenario where a CI/CD pipeline uploads build artifacts to a private Gitea release. These artifacts might contain debug symbols, hardcoded API keys, or intellectual property. An insider threat—or someone who has compromised a low-level account—can use this vulnerability to exfiltrate gigabytes of data without triggering typical "mass download" alarms, because the traffic looks like legitimate usage of the public interface.

Furthermore, because the file is now hosted on a public URL, the attacker doesn't just download it; they can share the link with others. The file is effectively "laundered" through the public repository.

The Fix: Remediation

The fix is straightforward: Update to Gitea 1.25.4. The patch introduces the necessary validation logic to prevent cross-repository attachment linking.

If you cannot upgrade immediately, you are in a tight spot. There are no easy configuration toggles to disable this specific behavior. You would need to implement a Web Application Firewall (WAF) rule that inspects the body of POST requests to release/issue endpoints, but validating UUID ownership at the WAF level is practically impossible without querying the database.

Post-Exploitation Forensics: If you suspect this has happened, you need to query your database. You can run a SQL query joining the attachment table with the repository table (via releases or issues) to find discrepancies.

-- Pseudo-SQL to find 'smuggled' attachments
SELECT * FROM attachment a
JOIN release r ON a.release_id = r.id
WHERE a.repo_id != r.repo_id;

If that query returns rows, someone has linked an attachment from one repo to a release in another. Investigate immediately.

Fix Analysis (1)

Technical Appendix

CVSS Score
9.1/ 10
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N
EPSS Probability
0.02%
Top 97% most exploited

Affected Systems

Gitea Server

Affected Versions Detail

Product
Affected Versions
Fixed Version
Gitea
Gitea
<= 1.25.31.25.4
AttributeDetail
CWE IDCWE-639
Attack VectorNetwork
CVSS Score9.1 (Critical)
EPSS Score0.00017
ImpactHigh Confidentiality Loss
Exploit StatusNo Public PoC
CWE-639
Authorization Bypass Through User-Controlled Key

Authorization Bypass Through User-Controlled Key

Vulnerability Timeline

Fix commit merged to master
2026-01-12
Gitea 1.25.4 Released
2026-01-22
CVE Published
2026-01-22

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.