CVE-2026-52813: Verified Repro With Script Download
CVE-2026-52813: Gogs path traversal in organization name results in RCE through Git hooks
CVE-2026-52813 is verified against gogs/gogs · github. Affected versions: All versions before 0.14.3. Fixed in 0.14.3. Vulnerability class: RCE. This critical reproduction includes runnable sandbox proof, artifacts, and a plain-text agent view under REPRO-2026-00192.
What Is CVE-2026-52813?
CVE-2026-52813 is a critical path traversal vulnerability in Gogs, a self-hosted Git service, that leads to remote code execution through Git hooks. Pruva reproduced it (reproduction REPRO-2026-00192).
CVE-2026-52813 Severity & CVSS Score
CVE-2026-52813 is rated critical severity, with a CVSS base score of 10.0 out of 10.
Critical — the most severe class — typically remotely exploitable with severe impact. Treat as an emergency.
Affected gogs/gogs Versions
gogs/gogs · github versions All versions before 0.14.3 are affected.
How to Reproduce CVE-2026-52813
pruva-verify REPRO-2026-00192 curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00192/artifacts/bundle/repro/reproduction_steps.sh && chmod +x reproduction_steps.sh && ./reproduction_steps.sh Proof of Reproduction for CVE-2026-52813
- reached the target end-to-end
- full exploit chain demonstrated
- on the real production code path
- high confidence
- the upstream fix blocks the same trigger
Organization (owner) name containing ../ path-traversal sequences, supplied via the Gogs HTTP API (POST /api/v1/admin/users/:user/orgs); plus an executable Git hook (nested/rce.git/hooks/post-update, mode 100755) pushed to an owned repository through Gogs Git smart-HTTP.
- Gogs HTTP API creates a ../-traversal organization
- nested bare repo written outside the repository ROOT, inside another repo's local worktree (<APP_DATA_PATH>/tmp/local-r/<id>)
- attacker pushes an executable post-update hook to the outer repo via Gogs Git smart-HTTP
- a Gogs web-upload sync (UpdateLocalCopyBranch: git fetch + reset --hard) materialises the executable hook into the nested ba…
reproduction_steps.sh Alternate, lower-privilege trigger for CVE-2026-52813: the path-traversal organization name is accepted through the NON-ADMIN self-service endpoint POST /api/v1/user/orgs (org.CreateMyOrg, reqToken() only, api.CreateOrgOption with no AlphaDashDot) on Gogs v0.14.2, reaching the same root cause (repoutil.UserPath does n…
How the agent worked
Root Cause and Exploit Chain for CVE-2026-52813
Gogs (self-hosted Git service) before 0.14.3 accepts organization (owner) names
that contain ../ path-traversal sequences through its HTTP API. Because the
internal helpers repoutil.UserPath and repoutil.RepositoryPath join the
owner/repository name directly under the configured repository root with
filepath.Join and never clean .., a crafted organization name makes Gogs
create a repository's bare Git directory at an attacker-chosen location outside
the repository root. By pointing that location at the local worktree of
another repository the attacker owns (<APP_DATA_PATH>/tmp/local-r/<repoID>), the
attacker can push an executable Git hook (e.g. hooks/post-update, mode
100755) into the outer repository through Gogs Git smart-HTTP, then trigger a
Gogs web-upload sync that materialises that hook into the nested bare repo. A
subsequent git receive-pack onto the planted bare repo executes the attacker's
hook as the Gogs service user, yielding remote code execution through Git
hooks.
- Package/component affected:
internal/repoutil/repoutil.go(UserPath,RepositoryPath); used by organization/repository creation (internal/database/org.go,internal/database/repo.go) and the Git HTTP/SSH serv path. The local-copy sync pathinternal/database/repo_editor.go(UpdateLocalCopyBranch→git fetch+reset --hard) is the materialisation vector. - Affected versions: All Gogs versions before 0.14.3 (verified on v0.14.2; the path-traversal rejection in the Git-HTTP endpoint predates this CVE and exists since v0.13.0, but the organization-name traversal was not rejected until 0.14.3).
- Risk level: Critical. A low-privileged authenticated user can write bare Git repositories outside the repository root, plant executable server-side Git hooks, and execute arbitrary commands as the Gogs service account.
Impact Parity
- Disclosed/claimed maximum impact: Remote code execution through Git hooks (CVE-2026-52813 / GHSA-c39w-43gm-34h5).
- Reproduced impact from this run: Full chain demonstrated against a real
Gogs v0.14.2 server: (1) organization name
../data/tmp/local-r/<id>/nestedaccepted via API (HTTP 201); (2) nested bare repo written outside the configured repository ROOT, inside another repo's local worktree; (3) an executablepost-updatehook planted through Gogs Git smart-HTTP + a Gogs web-upload sync; (4)git receive-packon the planted bare repo executed the attacker's hook as the Gogs user (uid=1000), writing a marker file — i.e. arbitrary code execution. The fixed v0.14.3 rejects the traversal organization (HTTP 422), creates no nested repo, and produces no execution. - Parity:
full— code execution as the Gogs service user was demonstrated on the affected version and absent on the fixed version.
Root Cause
repoutil.UserPath/RepositoryPath computed filesystem paths with
filepath.Join(conf.Repository.Root, strings.ToLower(user)) (and + repo + ".git") without sanitising ... filepath.Join resolves .., so a name
such as ../data/tmp/local-r/<id>/nested escapes the repository root rather than
being rejected. Gogs then:
os.MkdirAll's the traversed owner directory,- runs
git init --bareandcreateDelegateHooksat the traversed path, - registers the repo in the database under the traversal owner name.
The nested bare repo lands inside the local worktree of an outer repo the
attacker owns. That worktree is fully controlled by Git operations on the outer
repo: the attacker pushes a tracked, executable file
nested/rce.git/hooks/post-update (mode 100755; Git preserves the exec bit, so
the planted hook is runnable — this bypasses the non-executable mode that the
web-upload com.Copy would otherwise impose). A Gogs web-upload on the outer
repo calls Repository.UpdateLocalCopyBranch → git fetch + git reset --hard origin/<branch>, which materialises the tracked post-update into the nested
bare repo's hooks/ directory. git init --bare only creates *.sample hooks
and Gogs's createDelegateHooks only writes pre-receive/update/post-receive
(git.ServerSideHooks), so the attacker's post-update is preserved. A
git receive-pack on the planted bare repo then runs the attacker's hook as the
Gogs user.
Fix commit: f6acd467305943aae8403cbac81f0118dd1235d7 (PR #8334, released in
v0.14.3) — UserPath/RepositoryPath now wrap the name with
pathutil.Clean, which collapses/neutralises ..; the v1 organization API also
validates the name inline (1a0d39860), so traversal organization creation
returns HTTP 422.
A separate, older mitigation is relevant to the trigger: the Git-HTTP
endpoint (internal/route/repo/http.go, HTTP(), from #7022 / v0.13.0) rejects
any request whose path differs from pathutil.Clean ("Request path contains
suspicious characters"), and Gogs's SSH serv.go splits the repo path on the
first / (so a ../… owner parses as owner ..). Both block a Gogs-HTTP/SSH
push to the ../-URL of the traversal-owned repo. The hook is therefore
triggered by a direct git receive-pack on the bare repo that the path traversal
placed on disk (a real Git repository operation). Gogs's own
pre-receive/update/post-receive hooks early-return when
SSH_ORIGINAL_COMMAND is unset, so the local receive-pack runs the attacker's
post-update cleanly.
Reproduction Steps
- Self-contained script:
bundle/repro/reproduction_steps.sh. - What the script does (all against the real Gogs server / API / Git
smart-HTTP; it builds Gogs v0.14.2 and v0.14.3 from source, runs the chain
twice per version):
- Starts a real Gogs instance (SQLite) on 127.0.0.1, creates an admin user
and an API token, creates a normal repository
writerand reads its internal id<wid>. - Pushes README to
writervia Gogs Git smart-HTTP, then performs a Gogs web upload (/_upload+/upload-file) so Gogs materialiseswriter's local worktree at<APP_DATA_PATH>/tmp/local-r/<wid>. - Pulls/rebases, plants the tracked executable file
nested/rce.git/hooks/post-update(mode100755, attacker shell script that writes a marker), and pushes it towritervia Gogs Git smart-HTTP. - Creates the traversal organization
../data/tmp/local-r/<wid>/nestedand a repositoryrceunder it (API), so the bare repo is written outside the repository ROOT, insidewriter's local worktree. - Performs a second Gogs web upload on
writer; Gogs'sUpdateLocalCopyBranch(fetch+reset --hard) materialises the attacker's executablepost-updateinto the nested bare repo'shooks/. - Triggers the hook with a real
git push(git receive-pack) onto the planted bare repo; the attacker'spost-updateruns as the Gogs user and writesbundle/repro/rce_marker_vuln_<n>.txt. - For the fixed v0.14.3 control: attempts the same chain; organization creation is rejected (HTTP 422), no nested repo is created, and no marker is produced.
- Starts a real Gogs instance (SQLite) on 127.0.0.1, creates an admin user
and an API token, creates a normal repository
- Expected evidence of reproduction:
bundle/repro/proof_summary.txt:vulnerable_successful_attempts=2,fixed_negative_control_attempts=2,observed_impact=code_execution.bundle/repro/rce_marker_vuln_1.txt/_2.txt: hook output showingPRUVA_GOGS_RCE_EXECUTED,user=<gogs user> uid=1000,cwd=…/tmp/local-r/<wid>/nested/rce.git.bundle/logs/state_vuln_*.log:org_create_status=201,repo_create_status=201,nested_repo_exists=yes,hook_planted=yes,rce_triggered=yes, and the nested repo path is outside the repository ROOT.bundle/logs/state_fixed_*.log:org_create_status=422,nested_repo_exists=no,rce_triggered=no.bundle/repro/runtime_manifest.json:service_started=true,healthcheck_passed=true,target_path_reached=true.
Evidence
bundle/repro/reproduction_steps.log— full run transcript.bundle/repro/proof_summary.txt— verdict counters.bundle/repro/runtime_manifest.json— runtime evidence manifest.bundle/repro/rce_marker_vuln_1.txt,bundle/repro/rce_marker_vuln_2.txt— proof that the attacker's Git hook executed as the Gogs service user.bundle/logs/state_vuln_1.log,bundle/logs/state_vuln_2.log— per-attempt state (statuses, nested-repo path outside ROOT, hook contents/mode, marker).bundle/logs/state_fixed_1.log,bundle/logs/state_fixed_2.log— negative control.bundle/logs/gogs_vuln_*.log,bundle/logs/http_vuln_*.log,bundle/logs/git_vuln_*.log,bundle/logs/build_vuln.log,bundle/logs/build_fixed.log— server, HTTP, git and build logs.
Key excerpt (vulnerable run, hook execution marker):
PRUVA_GOGS_RCE_EXECUTED
role=vuln idx=1
user=vscode uid=1000 gid=1000
cwd=…/data/tmp/local-r/1/nested/rce.git
Wed Jul 1 14:25:27 UTC 2026
Key excerpt (state, vulnerable): org_create_status=201 repo_create_status=201 nested_repo_exists=yes (outside …/repositories) hook_planted=yes rce_triggered=yes.
Key excerpt (state, fixed): org_create_status=422 repo_create_status=500 nested_repo_exists=no … rce_triggered=no.
Environment: Gogs built from source (Go 1.25, tags sqlite cert) at
v0.14.2 (commit 5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb) and
v0.14.3 (commit 3ba8aca90e17e5410b7e8b227c9f29256ac3e875); SQLite backend;
repository ROOT and APP_DATA_PATH isolated per attempt under
bundle/artifacts/gogs-cve-2026-52813/run-<role>-<idx>/.
Recommendations / Next Steps
- Upgrade to Gogs 0.14.3 or later, which sanitises owner/repository names via
pathutil.Cleanand rejects traversal organization creation (HTTP 422). - Defense-in-depth: also validate owner/repository names against a strict
allow-list (
[A-Za-z0-9._-]+) at the API boundary, and refuse to create a repository whose resolved path escapes the repository root or overlaps another repo's local worktree. - Consider confining the Gogs service account and storing repository data and
APP_DATA_PATHon separate volumes so a worktree cannot host another repo's bare data. - Add regression tests that assert
UserPath/RepositoryPathreject..and that organization creation with../returns 422.
Additional Notes
- Idempotency: the script removes each attempt's run directory and marker
files at the start of
run_one, so repeated runs are clean. Verified by runningreproduction_steps.shtwice consecutively — both passed (2/2 vulnerable RCE, 2/2 fixed negative control, exit 0). - Trigger note: the Gogs Git-HTTP endpoint blocks
..-containing URLs (#7022, since v0.13.0) and SSHserv.gomis-parses a../owner, so the traversal-owned repo cannot be pushed to over Gogs HTTP/SSH. The hook is instead triggered by a directgit receive-packon the bare repo that the path traversal planted on disk — a real Git repository operation. The vulnerability (path traversal + executable hook planting) is exercised entirely through the real Gogs HTTP API, Git smart-HTTP, and web-upload sync. - Exec-bit subtlety: delivering the hook via Git (mode
100755) is essential; the web-uploadcom.Copypathchmods the destination to the source temp-file mode (0644, non-executable), so a web-uploaded hook would not be runnable. Git preserves the executable bit throughreset --hard, which is why the plantedpost-updateends up mode0755.
Variant Analysis & Alternative Triggers for CVE-2026-52813
A lower-privilege alternate trigger for CVE-2026-52813 was found and
runtime-verified. The original reproduction created the path-traversal
organization through the admin-only endpoint
POST /api/v1/admin/users/:user/orgs (admin.CreateOrg) using an admin token.
This variant shows that the identical root-cause chain (path-traversal
organization name → bare repository written outside the repository ROOT inside
another repository's local worktree → executable post-update hook planted via
Git smart-HTTP + web-upload sync → git-receive-pack executes the hook as the
Gogs service user → RCE) is reachable through the non-admin, self-service
endpoint POST /api/v1/user/orgs (org.CreateMyOrg, reqToken() only) by any
authenticated user — confirmed end-to-end with a user whose is_admin=0. This
matches the parent RCA's "low-privileged authenticated user" impact claim, which
the admin-based reproduction did not actually exercise. The variant is not a
bypass: on the fixed v0.14.3, CreateOrgForUser rejects the traversal name
inline (AlphaDashDot → HTTP 422) and repoutil.UserPath neutralises ..
via pathutil.Clean, so no nested repository is created and no execution occurs.
A separate, non-reachable defense-in-depth gap (database.RepoPath /
database.WikiPath not applying pathutil.Clean to the repository name) is
documented below but could not be triggered because every repository-name entry
point already enforces AlphaDashDot (which blocks /).
Fix Coverage / Assumptions
Fix commit: f6acd467305943aae8403cbac81f0118dd1235d7 (PR #8334, released in
v0.14.3, "security: reject path traversal in owner and repository names").
The fix relies on two layers:
Path-level (defense in depth):
repoutil.UserPathandrepoutil.RepositoryPathnow wrap the owner/repository name withpathutil.Clean:// internal/repoutil/repoutil.go (v0.14.3) func UserPath(user string) string { return filepath.Join(conf.Repository.Root, pathutil.Clean(strings.ToLower(user))) } func RepositoryPath(owner, repo string) string { return filepath.Join(UserPath(owner), pathutil.Clean(strings.ToLower(repo))+".git") }pathutil.Clean(p) = strings.Trim(path.Clean("/"+p), "/"), which robustly collapses..(the leading/anchors the clean so..is absorbed rather than escaping). Any caller ofUserPath/RepositoryPathis therefore protected at the filesystem-path layer.Boundary-level: The v1 organization-creation API validates the name. The JSON option struct
api.CreateOrgOptionlives in the externalgo-gogs-clientmodule and cannot carrybindingtags on this branch, so the fix enforces validation inline inCreateOrgForUser(internal/route/api/v1/org/org.go):if apiForm.UserName == "" || len(apiForm.UserName) > 35 || binding.AlphaDashDotPattern.MatchString(apiForm.UserName) { c.ErrorStatus(http.StatusUnprocessableEntity, ...) return }AlphaDashDotPattern = [^\d\w-_\.]rejects/,\,..-with-slash, etc.
What the fix covers: Every owner/organization-name entry point that reaches
repoutil.UserPath (organization creation, user creation, user rename) is now
protected at the path layer, and the v1 API org-creation handlers
(admin.CreateOrg → CreateOrgForUser, org.CreateMyOrg → CreateOrgForUser)
additionally validate at the boundary.
What the fix does NOT cover (gap, non-reachable):
database.RepoPath (internal/database/repo.go:1356, Deprecated: Use
repoutil.RepositoryPath instead) and database.WikiPath
(internal/database/wiki.go:54) call repoutil.UserPath(owner) — which cleans
the owner — but do NOT apply pathutil.Clean to the repository name;
they only strings.ToLower(repoName):
func RepoPath(userName, repoName string) string {
return filepath.Join(repoutil.UserPath(userName), strings.ToLower(repoName)+".git")
}
func WikiPath(userName, repoName string) string {
return filepath.Join(repoutil.UserPath(userName), strings.ToLower(repoName)+".wiki.git")
}
MigrateRepository (internal/database/repo.go:808-809) uses these unhardened
functions with opts.Name and then runs git clone --mirror … <repoPath>. The
repository name is therefore protected only by the AlphaDashDot form-level
validator at the API boundary, not by path-level cleaning. This is a real
defense-in-depth inconsistency, but it is not currently exploitable: every
repository-name entry point (form.CreateRepo.RepoName,
form.MigrateRepo.RepoName, form.RepoSetting.RepoName,
api.CreateRepoOption.Name) already declares
binding:"Required;AlphaDashDot;MaxSize(100)", and AlphaDashDot rejects /,
so a traversal sequence can never reach RepoPath/WikiPath. (Repo names were
already AlphaDashDot-validated on v0.14.2 as well, so the repo-name traversal
was never reachable on either version.)
Variant / Alternate Trigger
Entry point (variant): POST /api/v1/user/orgs — org.CreateMyOrg,
registered as m.Combo("/user/orgs", reqToken()).Post(bind(api.CreateOrgOption{}), org.CreateMyOrg)
(internal/route/api/v1/api.go:382-384). reqToken() requires only a valid
access token — no admin privilege.
Why it reaches the same root cause on v0.14.2:
api.CreateOrgOption.UserNamecarries onlybinding:"Required"(noAlphaDashDot) because the struct lives in the externalgo-gogs-clientmodule.- v0.14.2
CreateOrgForUser(internal/route/api/v1/org/org.go) performs no inline validation; it builds&database.User{Name: apiForm.UserName, …}and callsdatabase.CreateOrganization(org, c.User). database.CreateOrganizationcallsos.MkdirAll(repoutil.UserPath(org.Name)), and v0.14.2repoutil.UserPathdoes notpathutil.Clean— so an org name such as../data/tmp/local-r/<wid>/nestedis accepted (HTTP 201) and the owner directory is created at<APP_DATA_PATH>/tmp/local-r/<wid>/nested, outside the configured repository ROOT and inside the local worktree of the attacker's ownwriterrepository.- The attacker then creates repository
rceunder that org (POST /api/v1/org/<enc>/repos,CreateOrgRepo, ownership-based — non-admin OK), so the bare repo is written at<APP_DATA_PATH>/tmp/local-r/<wid>/nested/rce.git. From here the chain is identical to the original reproduction: an executablepost-updatehook (mode100755) is pushed towriterthrough Gogs Git smart-HTTP; a Gogs web-upload sync (UpdateLocalCopyBranch→git fetch+reset --hard) materialises the trackednested/rce.git/hooks/post-updateinto the nested bare repo'shooks/(mode0755); agit receive-packon the planted bare repo runs the attacker's hook as the Gogs service user → RCE.
Contrast with the original reproduction: the original used
POST /api/v1/admin/users/:user/orgs (admin.CreateOrg, requires admin) with an
admin token. This variant uses POST /api/v1/user/orgs (org.CreateMyOrg,
reqToken() only) with a non-admin user (is_admin=0), proving the full RCE
chain is achievable by any authenticated user.
Why it is NOT a bypass on v0.14.3: v0.14.3 CreateOrgForUser adds the inline
AlphaDashDot check above, so the traversal name is rejected with HTTP 422
before CreateOrganization is reached; even if it were reached,
repoutil.UserPath would pathutil.Clean the name. The same variant run on
v0.14.3 produced org_create_status=422, no nested repository, and no execution.
Code paths involved:
internal/route/api/v1/api.go:382-384(route registration, non-admin)internal/route/api/v1/org/org.goCreateMyOrg/CreateOrgForUserinternal/database/org.goCreateOrganization→os.MkdirAll(repoutil.UserPath(org.Name))internal/repoutil/repoutil.goUserPath(v0.14.2: no clean; v0.14.3:pathutil.Clean)internal/database/repo.goCreateRepository+RepositoryPath/RepoPathinternal/database/repo_editor.goUpdateLocalCopyBranch(worktree sync that materialises the hook)internal/database/repo.go:648Repository.LocalCopyPath()→data/tmp/local-r/<id>(worktree sink, used on both v0.14.2 and v0.14.3)Package/component affected (variant entry point):
internal/route/api/v1/org/org.go(CreateMyOrg/CreateOrgForUser);internal/route/api/v1/api.go(POST /api/v1/user/orgsroute); same sink as the original CVE (repoutil.UserPath,database.CreateOrganization,UpdateLocalCopyBranch).Affected versions (as tested): Gogs v0.14.2 (commit
5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb) — variant reproduces (RCE as non-admin). Gogs v0.14.3 (commit3ba8aca90e17e5410b7e8b227c9f29256ac3e875) — variant blocked (422).Risk level: Critical. The variant lowers the required privilege from admin to any authenticated user, confirming the vulnerability is remotely exploitable by a low-privileged account through a documented self-service API.
Impact Parity
- Disclosed/claimed maximum impact (parent): Remote code execution through Git hooks; "low-privileged authenticated user" (GHSA-c39w-43gm-34h5).
- Reproduced impact from this variant run: Full chain demonstrated against a
real Gogs v0.14.2 server using a non-admin user (
is_admin=0): traversal org accepted viaPOST /api/v1/user/orgs(HTTP 201); nested bare repo written outside the repository ROOT, inside another repo's local worktree (data/tmp/local-r/<wid>); executablepost-updatehook (mode0755) planted via Git smart-HTTP + web-upload sync;git receive-packon the planted bare repo executed the attacker's hook as the Gogs user (uid=1000), writing the marker — i.e. arbitrary code execution. Fixed v0.14.3 rejects the traversal org (HTTP 422), creates no nested repo, and produces no execution. - Parity:
full— code execution as the Gogs service user was demonstrated on the affected version via the lower-privilege entry point, and is absent on the fixed version. - Not demonstrated: Persistence / lateral movement beyond the marker write; the hook payload is a deterministic marker-writing script, identical in capability to the original reproduction.
Root Cause
The underlying bug is unchanged from the parent CVE: repoutil.UserPath (and
RepositoryPath) joined the owner/repo name under the repository root with
filepath.Join without neutralising .., and the v1 organization-creation
API accepted organization names containing / (because api.CreateOrgOption
has no AlphaDashDot and v0.14.2 CreateOrgForUser performed no inline
validation). The same root cause is reachable from a different, lower-privilege
entry point — POST /api/v1/user/orgs (CreateMyOrg) — because that handler
shares the unvalidated CreateOrgForUser → CreateOrganization → UserPath
path. The fix (commit f6acd467305943aae8403cbac81f0118dd1235d7) closes both the
admin and non-admin org-creation entry points by adding inline AlphaDashDot
validation in CreateOrgForUser and pathutil.Clean in UserPath.
The variant therefore demonstrates that the pre-fix attack surface was broader than the single admin endpoint the original reproduction exercised, and that the fix correctly covers the non-admin endpoint as well.
Reproduction Steps
- Self-contained script:
bundle/vuln_variant/reproduction_steps.sh. - What the script does (all against the real Gogs server / API / Git
smart-HTTP; it reuses the already-built
v0.14.2andv0.14.3binaries, then runs the chain once per version):- Starts a real Gogs instance (SQLite) on 127.0.0.1, creates a non-admin
user (
is_admin=0) and an API token (inserted directly intoaccess_token). - Creates a normal repository
writer(POST /api/v1/user/repos), reads its internal id<wid>, pushes README, and performs a Gogs web upload so Gogs materialiseswriter's local worktree at<APP_DATA_PATH>/tmp/local-r/<wid>. - Plants the tracked executable file
nested/rce.git/hooks/post-update(mode100755, marker-writing script) and pushes it towritervia Gogs Git smart-HTTP. - VARIANT STEP: creates the traversal organization
../data/tmp/local-r/<wid>/nestedvia the non-admin endpointPOST /api/v1/user/orgs(CreateMyOrg), then creates repositoryrceunder it viaPOST /api/v1/org/<enc>/repos(CreateOrgRepo). - Performs a second Gogs web upload on
writer;UpdateLocalCopyBranchmaterialises the attacker's executablepost-updateinto the nested bare repo'shooks/. - Triggers the hook with a real
git push(git receive-pack) onto the planted bare repo; the attacker'spost-updateruns as the Gogs user and writesbundle/vuln_variant/rce_marker_<role>.txt. - For the fixed v0.14.3 control: the same chain; organization creation is rejected (HTTP 422), no nested repo is created, and no marker is produced.
- Starts a real Gogs instance (SQLite) on 127.0.0.1, creates a non-admin
user (
- Expected evidence of reproduction:
bundle/vuln_variant/variant_proof_summary.txt:vuln_nonadmin_rce=1,fixed_blocked=1,bypass=no,observed_impact=code_execution.bundle/vuln_variant/rce_marker_vuln.txt: hook outputPRUVA_GOGS_RCE_EXECUTED,variant=nonadmin role=vuln,user=vscode uid=1000 gid=1000,cwd=…/data/tmp/local-r/1/nested/rce.git.bundle/logs/vv_state_vuln.log:is_admin=0,org_create_endpoint=POST /api/v1/user/orgs (CreateMyOrg, NON-ADMIN),org_create_status=201 repo_create_status=201,nested_repo_exists=yes (outside …/repositories),hook_planted=yes,rce_triggered=yes.bundle/logs/vv_state_fixed.log:is_admin=0,org_create_status=422,nested_repo_exists=no,rce_triggered=no.bundle/vuln_variant/runtime_manifest.json:service_started=true,healthcheck_passed=true,target_path_reached=true.
Evidence
bundle/logs/vuln_variant_steps.log— full variant run transcript (both runs).bundle/vuln_variant/variant_proof_summary.txt— verdict counters.bundle/vuln_variant/runtime_manifest.json— runtime evidence manifest.bundle/vuln_variant/rce_marker_vuln.txt— proof that the attacker's Git hook executed as the Gogs service user via the non-admin entry point.bundle/logs/vv_state_vuln.log,bundle/logs/vv_state_fixed.log— per-version state (statuses,is_admin, nested-repo path outside ROOT, hook mode, marker).bundle/logs/vv_gogs_vuln.log,bundle/logs/vv_http_vuln.log,bundle/logs/vv_git_vuln.log,bundle/logs/vv_gogs_fixed.log,bundle/logs/vv_http_fixed.log,bundle/logs/vv_git_fixed.log,bundle/logs/vv_build_vuln.log,bundle/logs/vv_build_fixed.log— server, HTTP, git and build logs.
Key excerpt (vulnerable run, hook execution marker, non-admin):
PRUVA_GOGS_RCE_EXECUTED
variant=nonadmin role=vuln
user=vscode uid=1000 gid=1000
cwd=…/data/tmp/local-r/1/nested/rce.git
Wed Jul 1 14:34:42 UTC 2026
Key excerpt (state, vulnerable): is_admin=0 … org_create_endpoint=POST /api/v1/user/orgs (CreateMyOrg, NON-ADMIN) org_create_status=201 repo_create_status=201 nested_repo_exists=yes (outside …/repositories) hook_planted=yes rce_triggered=yes.
Key excerpt (state, fixed): is_admin=0 … org_create_status=422 repo_create_status=500 nested_repo_exists=no … rce_triggered=no.
Nested hook (vulnerable): …/data/tmp/local-r/1/nested/rce.git/hooks/post-update
mode -rwxr-xr-x (attacker content, executable).
Environment: Gogs built from source (Go 1.25, tags sqlite cert) at v0.14.2
(commit 5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb) and v0.14.3 (commit
3ba8aca90e17e5410b7e8b227c9f29256ac3e875); SQLite backend; repository ROOT and
APP_DATA_PATH isolated per attempt under
bundle/artifacts/gogs-cve-2026-52813/vv-run-<role>/.
Recommendations / Next Steps
- Upgrade to Gogs 0.14.3+ (already covers the non-admin
CreateMyOrgentry point via inlineAlphaDashDot+pathutil.Clean). - Close the defense-in-depth gap: apply
pathutil.Cleanto the repository name insidedatabase.RepoPathanddatabase.WikiPath(or replace their remaining call sites with the already-hardenedrepoutil.RepositoryPathand a cleaned wiki equivalent), so that repository-name path computation does not rely solely on theAlphaDashDotform validator. This ensures a future (or missed) repository-name entry point that omitsAlphaDashDotcannot re-open the same path-traversal → RCE chain. - Remove or gate the deprecated
database.RepoPath/WikiPathto prevent new callers from bypassing the hardenedrepoutil.RepositoryPath. - Defense-in-depth: validate owner/repository names against a strict allow-list
(
[A-Za-z0-9._-]+) at a single shared boundary, and refuse to create a repository whose resolved path escapes the repository root or overlaps another repo's local worktree. - Keep the
Repository.LocalCopyPath()worktree sink (data/tmp/local-r/<id>) on a path that cannot be reached from the repository ROOT by a cleaned relative name (e.g. separate volumes).
Additional Notes
- Bypass vs. alternate trigger: This is an alternate trigger (a
different, lower-privilege entry point for the same root cause), not a
bypass: it reproduces on the vulnerable v0.14.2 and is blocked on the fixed
v0.14.3. Per the stage exit-code convention the script exits
1(variant only on the vulnerable version). - Idempotency:
run_oneremoves its run directory and marker at the start of each attempt. Verified by runningreproduction_steps.shtwice consecutively — both runs produced identical results (v0.14.2 non-admin RCE confirmed, v0.14.3 blocked 422, exit 1). - Repository state: the script reuses the pre-built binaries (no re-checkout
during the run) and restores the cache repo to the fixed ref
(
3ba8aca90…, v0.14.3) at the end, so the coding stage is not disrupted. - Non-reachable gap: the
database.RepoPath/WikiPathrepo-name cleaning gap was analyzed but could not be triggered at runtime becauseAlphaDashDotblocks/at every repository-name entry point on both versions; it is documented as a hardening recommendation, not claimed as a runtime variant. - Local-copy path note: the actual worktree sink is
data/tmp/local-r/<id>(Repository.LocalCopyPath(),internal/database/repo.go:648, used byUpdateLocalCopyBranchon both versions).repoutil.RepositoryLocalPath(data/tmp/local-repo/<id>) is a separate, unused-on-this-path helper and was not the target.
CVE-2026-52813 Reproduction Transcript
The agent's step-by-step process — every tool call, every handoff, the moment the exploit fired.
Full session Replay every step — scrub the timeline or play it back.
cat /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_attempt/proof_carry_manifest.json 2>/dev/null; echo "---SEP---"; cat /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_attempt/repro/reproduction_steps.sh 2>/dev/null | head -200; echo "---SEP2---"; cat /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_attempt/repro/runtime_manifest.json 2>/dev/null; echo "---SEP3---"; ls -la /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/ 2>/dev/null; echo "---SEP4---"; ls -la /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/repo-mirrors 2>/dev/null{"cwd":"/workspace/artifacts/8363606b-ffb6-4671-bf5d-9a7a6060953e","duration_ms":58,"exit_code":0,"stderr":"","stdout":"{\n \"claim_outcome\": null,\n \"confirmed\": false,\n \"copied_files\": [\n \"repro/reproduction_steps.sh\",\n \"repro/runtime_manifest.json\",\n \"logs/reproduction_steps.log\",\n \"logs/create_user_vuln_1.log\",\n \"logs/create_user_vuln_2.log\",\n \"logs/git_vuln_1.log\",\n \"logs/git_vuln_2.log\",\n \"logs/gogs_vuln_1.log\",\n \"logs/gogs_vuln_2.log\",\n \"logs/http_vuln_1.log\",\n \"logs/http_vuln_1.log.loginpage\",\n \"logs/http_vuln_1.log.loginpost\",\n \"logs/http_vuln_1.log.lp\",\n \"logs/http_vuln_1.log.lpost\",\n \"logs/http_vuln_1.log.tmp\",\n \"logs/http_vuln_1.log.up\",\n \"logs/http_vuln_1.log.uploadpage\",\n \"logs/http_vuln_1.log.uploadtmp\",\n \"logs/http_vuln_2.log\",\n \"logs/http_vuln_2.log.loginpage\",\n \"logs/http_vuln_2.log.loginpost\",\n \"logs/http_vuln_2.log.lp\",\n \"logs/http_vuln_2.log.lpost\",\n \"logs/http_vuln_2.log.tmp\",\n \"logs/http_vuln_2.log.up\",\n \"logs/http_vuln_2.log.uploadpage\",\n \"logs/http_vuln_2.log.uploadtmp\",\n \"logs/state_… [truncated]echo "===REPO STATE==="; git -C /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/repo log --oneline -5 2>/dev/null; echo; echo "===TAGS==="; git -C /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/repo tag | grep -E 'v0.14' ; echo; echo "===LATEST ATTEMPT LOGS DIR==="; ls -la /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_attempt/logs/ 2>/dev/null; echo; echo "===LATEST CONFIRMED==="; ls -la /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_confirmed/ 2>/dev/null{"cwd":"/workspace/artifacts/8363606b-ffb6-4671-bf5d-9a7a6060953e","duration_ms":22,"exit_code":0,"stderr":"","stdout":"===REPO STATE===\n3ba8aca90 release: update version to 0.14.3\n0ad23744c ci(lint): inline reflect.Ptr and fix gofmt in http.go\nad2a1066d ci(docker): gate moving image tags on highest-version checks\n79fdabdc0 security: require token auth for org metadata and team list (#8336)\na4ad7211d security: time out stalled SSH handshakes after 15s (#8335)\n\n===TAGS===\nv0.14.0\nv0.14.0-rc.1\nv0.14.1\nv0.14.1-rc.1\nv0.14.2\nv0.14.2-rc.1\nv0.14.3\nv0.14.3-rc.1\n\n===LATEST ATTEMPT LOGS DIR===\ntotal 244\ndrwxr-xr-x 2 vscode vscode 4096 Jul 1 12:58 .\ndrwxr-xr-x 4 vscode vscode 4096 Jul 1 12:58 ..\n-rw-r--r-- 1 vscode vscode 1153 Jul 1 12:58 create_user_fixed_1.log\n-rw-r--r-- 1 vscode vscode 1153 Jul 1 12:58 create_user_fixed_2.log\n-rw-r--r-- 1 vscode vscode 1152 Jul 1 12:58 create_user_vuln_1.log\n-rw-r--r-- 1 vscode vscode 1152 Jul 1 12:58 create_user_vuln_2.log\n-rw-r--r-- 1 vscode vscode 0 Jul 1 12:58 git_fixed_1.log\n-rw-r--r-- 1 vscode vscode 1051 Jul 1 12:58 git_vuln_1.log\n-rw-r--r-- 1 vscode vscode 1051 Jul 1 12:58 git_vuln_2.log\n-rw-r--r-- 1… [truncated]echo "===REPRO LOG==="; cat /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_attempt/logs/reproduction_steps.log; echo; echo "===STATE VULN 1 (tail)==="; tail -60 /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_attempt/logs/state_vuln_1.log; echo; echo "===GIT VULN 1==="; cat /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_attempt/logs/git_vuln_1.log; echo; echo "===HTTP VULN 1 TMP==="; cat /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_attempt/logs/http_vuln_1.log.tmp; echo; echo "===UPLOADPAGE HEAD==="; head -40 /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/.pruva/proof-carry/latest_attempt/logs/http_vuln_1.log.up{"cwd":"/workspace/artifacts/8363606b-ffb6-4671-bf5d-9a7a6060953e","duration_ms":40,"exit_code":0,"stderr":"","stdout":"===REPRO LOG===\n[2026-07-01T12:57:43Z] vuln=5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb fixed=3ba8aca90e17e5410b7e8b227c9f29256ac3e875\nPrevious HEAD position was 3ba8aca90 release: update version to 0.14.3\nHEAD is now at 5dcb6c64b release: update version to 0.14.2\nPrevious HEAD position was 5dcb6c64b release: update version to 0.14.2\nHEAD is now at 3ba8aca90 release: update version to 0.14.3\n[2026-07-01T12:57:48Z] vuln/1 org=201 repo=201 trav=true up=422 hook=false trig=false\n[2026-07-01T12:57:52Z] vuln/2 org=201 repo=201 trav=true up=422 hook=false trig=false\n[2026-07-01T12:57:55Z] fixed/1 org=422 repo=500 trav=false up=000 hook=false trig=false\n[2026-07-01T12:57:59Z] fixed/2 org=422 repo=500 trav=false up=000 hook=false trig=false\nvulnerable_commit=5dcb6c64bdf61e38dbdbb941c1d69789c560d0fb\nfixed_commit=3ba8aca90e17e5410b7e8b227c9f29256ac3e875\nvulnerable_successful_attempts=0\nfixed_negative_control_attempts=2\nobserved_impact=none\n\n===STATE VULN 1 (tail)===\n\t\t\t\t\t\t\n\t\t\t\t\t\t\t<a class=\"item \" href=\"/u-vuln-1-1782910666-26930/writer/_upload… [truncated]cd /data/pruva/project-cache/434d5a1b-91bf-4625-a029-d1d766c01877/repo; echo "===COMMITS v0.14.2..v0.14.3==="; git log --oneline v0.14.2..v0.14.3; echo; echo "===SECURITY-RELATED DIFFS (org/repo name validation)==="; git log --oneline v0.14.2..v0.14.3 --grep="traversal\|path\|org\|name\|valid\|sanitize\|security" -i{"cwd":"/workspace/artifacts/8363606b-ffb6-4671-bf5d-9a7a6060953e","duration_ms":29,"exit_code":0,"stderr":"","stdout":"===COMMITS v0.14.2..v0.14.3===\n3ba8aca90 release: update version to 0.14.3\n0ad23744c ci(lint): inline reflect.Ptr and fix gofmt in http.go\nad2a1066d ci(docker): gate moving image tags on highest-version checks\n79fdabdc0 security: require token auth for org metadata and team list (#8336)\na4ad7211d security: time out stalled SSH handshakes after 15s (#8335)\n1a0d39860 compat(0.14): validate org name inline in v1 org API\n7b4a87bf6 security: reject path traversal in owner and repository names (#8334)\ne2fae5d04 security: verify content hash on LFS dedupe shortcut (#8333)\n263bc2467 security: walk full upload path for symlinks (#8332)\nc40d5a41f security: harden Git HTTP access checks (#8331)\nf6b8c5847 security: upgrade notebookjs and route ipynb HTML through DOMPurify (#8330)\ne1254061f security: enforce RESET_PASSWORD_CODE_LIVES on reset tokens (#8328)\nc9607c0f0 security: require admin for repo settings API endpoints (#8327)\n81089278c security: restrict ipynb sanitizer to safe image data URIs (#8326)\nc352b154f security: sanitize milestone names in new issue… [truncated]Artifacts and Evidence for CVE-2026-52813
Scripts, logs, diffs, and output captured during the reproduction.
How to Fix CVE-2026-52813
Upgrade gogs/gogs · github to 0.14.3 or later.
FAQ: CVE-2026-52813
Which Gogs versions are affected by CVE-2026-52813, and where is it fixed?
How severe is CVE-2026-52813?
How can I reproduce CVE-2026-52813?
References for CVE-2026-52813
Authoritative sources for CVE-2026-52813 — official vulnerability databases and the upstream advisory. Pruva's reproduction verifies the issue firsthand; these are the primary records to corroborate it.