The Zombie Stopwatch: Haunting Gitea with Revoked Access
Jan 24, 2026·6 min read·3 visits
Executive Summary (TL;DR)
Gitea forgot to check if you still work there before showing you your timesheets. If a user started a stopwatch on a private issue and then lost access to the repository, the API would still happily serve up the issue title and repo name in the stopwatch list. It's a classic Broken Access Control (CWE-284) fixed in version 1.25.4.
A logic flaw in Gitea's stopwatch feature created a persistence vulnerability where users maintained visibility into private issue metadata after access revocation. By failing to re-validate permissions during API object serialization, the system allowed 'zombie' stopwatch records to leak sensitive titles and repository names.
The Hook: The Ghost in the Machine
In the world of access control, we usually worry about the front door. Can the attacker pick the lock? Can they steal a key? But sometimes, the vulnerability isn't about getting in; it's about what you leave behind when you're kicked out. Enter Gitea's Stopwatch feature—a seemingly innocent productivity tool that allows developers to track time on issues.
Imagine this: You are a contractor hired to work on a top-secret project. You start a timer on an issue titled "Critical Security Flaw in Authentication". A week later, you are fired. Your access to the repository is revoked. You can't see the code, you can't see the issues, and you definitely shouldn't know what the team is working on.
But because Gitea's developers treated the stopwatch as a personal metric rather than a repository-tied asset, that timer keeps ticking in your personal dashboard. And unlike a real physical stopwatch, this digital ghost carries the metadata of the thing it was timing. It's not just counting seconds; it's whispering secrets from a room you're no longer allowed to enter.
The Flaw: Trusting the Past
The root cause here is a classic failure in the Object-Relational Mapping (ORM) to API conversion layer. When you build an API, you often pull raw rows from a database and pass them through a "serializer" or "converter" to turn them into pretty JSON for the frontend. In Gitea, the function responsible for this was ToStopWatches in services/convert/issue.go.
The logic was dangerously simple: "Fetch all stopwatch records associated with User X." The code assumed that if a record existed in the database, the user must have permission to see it. It treated the database state as the source of truth for authorization, rather than checking the current access control list (ACL) at runtime.
This is a Time-of-Check to Time-of-Use (TOCTOU) variant effectively turned into a permanent state. Validating permission only when the stopwatch is started is insufficient. Permissions change. Teams change. Repositories go from public to private. By failing to re-validate the relationship between the user and the repository during the read operation (the API call), the code created a permanent window into the past state of authorization.
The Code: The Smoking Gun
Let's look at the difference between "it works" and "it's secure." The vulnerability existed because the conversion function was blind to the context of the user's current permissions.
The Vulnerable Logic (Pseudo-code):
// Old way: Blindly convert DB rows to JSON
func ToStopWatches(stopwatches []Stopwatch) []APIStopwatch {
var apiWatches []APIStopwatch
for _, sw := range stopwatches {
// Just grab the issue and repo details directly
repo := GetRepo(sw.RepoID)
issue := GetIssue(sw.IssueID)
// Dump it into the response
apiWatches = append(apiWatches, &APIStopwatch{
Repo: repo.Name,
Issue: issue.Title,
})
}
return apiWatches
}The Fix (Commit 95ea2df):
The patch introduces two critical changes. First, it passes the requesting user (doer) into the function. Second, it explicitly checks CanReadIssuesOrPulls. If you can't read the issue anymore, the stopwatch is effectively invisible to the API response.
// The Fix: Trust but Verify
func ToStopWatches(ctx, stopwatches, doer) {
for _, sw := range stopwatches {
repo := GetRepo(sw.RepoID)
// CRITICAL: Check permissions at runtime
perm, _ := access_model.GetUserRepoPermission(ctx, repo, doer)
// If the door is locked, don't tell them what's inside
if !perm.CanReadIssuesOrPulls(sw.Issue.IsPull) {
continue
}
// Safe to add to response
apiWatches = append(apiWatches, ...)
}
}This is the difference between asking "Did you have a key yesterday?" and "Do you have a key right now?"
The Exploit: Ex-Employee Revenge
Exploiting this requires no fancy tools—just standard API interactions. This is a "logic bug," which is my favorite kind because WAFs are useless against it.
Step 1: Infiltration Get added to a private repository. This could be legitimate employment, a contractor gig, or a compromised account that is temporarily added to a team.
Step 2: Tagging the Assets Browse the repository for sensitive issues. Maybe there's an issue named "Hardcoded AWS Keys in CI/CD" or "Layoff Strategy Q3". Start a stopwatch on every juicy issue you find. You don't need to log time; you just need to create the record in the DB.
Step 3: The Purge The admin removes your account from the organization. You verify via the UI: "404 Not Found" on the repository. The front door is locked.
Step 4: The Look-Back
Query the API directly:
GET /api/v1/user/stopwatches
The server responds with a JSON array containing the repo_name and issue_title for every stopwatch you started. You confirm the layoffs are happening. You see they haven't fixed the AWS keys yet. You have insider trading info, and you aren't even an insider anymore.
The Impact: Why Titles Matter
You might be thinking, "So what? I see a title. I don't see the code." Do not underestimate the value of metadata. In many organizations, issue titles contain high-level strategic information.
If I'm a competitor, knowing that you have an issue titled "Emergency Patch for Zero-Day in Payment Gateway" tells me everything I need to know about your current stability. If I'm an attacker, seeing "Refactor SQL Injection in Login" tells me exactly where to point sqlmap.
Furthermore, this persistence violates the fundamental security principle of Complete Mediation. Every access to every object must be checked for authority. Gitea failed this check, allowing a dangling reference to become an unauthorized peephole.
The Fix: Double Tap
The remediation provided by the Gitea team in version 1.25.4 is robust because it attacks the problem from two angles—the view layer and the data layer.
First, as detailed in the code section, the API presentation layer now filters out results you can't see. This stops the immediate bleeding. But leaving unauthorized records in the database is just messy hygiene.
So, the second part of the fix involves Active Cleanup. They modified services/repository/collaboration.go and services/org/team.go. Now, when a user is removed from a repository or a team, the backend triggers a cleanup routine RemoveStopwatchesByRepoID. It nukes the stopwatch records entirely.
If you are running Gitea <= 1.25.3, you are vulnerable. Upgrade immediately. If you can't upgrade, you need to manually audit your stopwatch database table for users who shouldn't be there, but frankly, upgrading is easier than writing SQL queries to cross-reference permission tables.
Official Patches
Fix Analysis (1)
Technical Appendix
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:NAffected Systems
Affected Versions Detail
| Product | Affected Versions | Fixed Version |
|---|---|---|
Gitea Gitea | <= 1.25.3 | 1.25.4 |
| Attribute | Detail |
|---|---|
| CVE ID | CVE-2026-20883 |
| CWE | CWE-284 (Improper Access Control) |
| CVSS Score | 6.5 (Medium) |
| Attack Vector | Network (API) |
| Fix Version | 1.25.4 |
| Impact | Information Disclosure |
MITRE ATT&CK Mapping
The software does not properly restrict access to a resource from an unauthorized actor.
Known Exploits & Detection
Vulnerability Timeline
Subscribe to updates
Get the latest CVE analysis reports delivered to your inbox.