Gitea, restore-repo file:// os.Open
Gitea 1.27.3 OpenWithClient still implements file:// with os.Open. Operator restore of an untrusted dump rewrites release DownloadURL to file:// under the dump. os.Open follows a dump symlink. Not live-migrate LFI. No CVE yet.
- Name
- Gitea, restore-repo file:// os.Open
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- medium
- Disclosure Status
- public
- Vendor
- Gitea
- Affected
- Gitea through v1.27.3 (146cc3e); unpublished. Local operator gitea restore-repo on an untrusted dump. FilePathJoinAbs dump-root escape does not land on this tag.
- Published
- 29 Sept 2026
- Updated
- 29 Sept 2026
- Tags
- 0-day, gitea, lfi, restore, cwe-73, authenticated
file:// still calls os.Open
I am @abraxas_null. The proof of concept is on GitHub: abraxas/gitea-restore-file-open (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, dump/. Authorized lab only. It talks to loopback.
This is Gitea v1.27.3 (146cc3e). No CVE yet. CWE-73. 4.2 Medium (CVSS:3.1/AV:L/AC:L/PR:H/UI:R/S:U/C:H/I:N/A:N).
How I found it
I had two restore ideas. I labbed the obvious one first.
The first restore idea was dump-root join escape: put an absolute DownloadURL in release.yml, hope FilePathJoinAbs drops the dump prefix the way naive filepath.Join(base, "/etc/passwd") does. On 1.27.3 it does not. Each sub is filepath.Clean("/"+s) then filepath.Join(base, cleaned). Replica join of /tmp/dump + /tmp/GITEA-JOIN-OUTSIDE stayed /tmp/dump/tmp/GITEA-JOIN-OUTSIDE. Restore rewrote file:// under the dump and failed open ...: no such file or directory. FAIL. That is not join-escape on this tag.
The second idea is what landed. CVE-2026-59765 put a hostmatcher HTTP client on OpenWithClient. The file:// branch ignores that client and calls os.Open. Restore of a dump has no DownloadFunc. gitea_uploader.go then opens the rewritten file:// URL. os.Open follows a dump-side symlink. Join stays under the dump. The symlink does not.
I planted a witness file outside the dump, put a symlink at the joined-under-dump path, ran gitea restore-repo. Restore rc=0. The restored release attachment contained GITEA-FILE-OPEN-WITNESS.
Live migrate from GitHub/GitLab/Gitea sets DownloadFunc. That path does not os.Open an attacker file://. PR PatchURL is cleared unless it prefixes the clone HTTP base. This is local operator restore of an untrusted dump, not remote LFI.
I am not printing a dump YAML you can hand an operator. The file:// -> os.Open branch is the useful part.
Wrong turns already recorded: dump-root join escape via an absolute DownloadURL; live migrate file:// LFI; X-Gitea-Internal-Auth (restore is CLI as the Gitea uid, not /api/internal); treating restore HTTP as remote unauth (CVSS is local, high privilege, user interaction); a reverse shell. Theatre.
The branch that ignores the client
func OpenWithClient(uriStr string, client *http.Client) (io.ReadCloser, error) {
u, err := url.Parse(uriStr)
if err != nil {
return nil, err
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
f, err := client.Get(uriStr)
// ...
case "file":
return os.Open(u.Path)Restore of a dump has no DownloadFunc:
if asset.DownloadFunc != nil {
rc, err = asset.DownloadFunc()
} else if asset.DownloadURL != nil {
rc, err = uri.OpenWithClient(*asset.DownloadURL, getMigrationHTTPClient())
}The HTTP client on this helper is real. file:// never uses it.
What an attacker can do
Hand an admin an untrusted dump (a "backup" directory, a restore from an untrusted remote) that contains a symlink under the joined-under-dump path. Restore copies the target file into a release attachment on the restored repo. Whoever can read that repo downloads it.
As the Gitea uid, that is any file the service account can read. If the dump author names app.ini, that is credential theft (INTERNAL_TOKEN, SECRET_KEY, JWT, DB password). Lab oracle was a planted witness file, not app.ini. It is not OS LPE. It is not unauthenticated. It is not dump-root join escape. Findings 2 and 3 cannot turn a stolen token into an internal-API call: git has no custom header, webhooks do not add X-Gitea-Internal-Auth.
The lab (run this at home)
Source of truth is lab/. Image gitea/gitea:1.27.3. Port 18137. Bind it to loopback. Compose mounts lab/dump read-only. run.sh plants the witness inside the container, builds the dump, puts a symlink at the joined-under-dump path, then gitea restore-repo.
# Loopback lab image pin for gitea-restore-file-open. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3name: gitea-restore-file-open
services:
gitea:
build: .
image: gitea/gitea:1.27.3
ports:
- "127.0.0.1:18137:3000"
environment:
USER_UID: "1000"
USER_GID: "1000"
GITEA__database__DB_TYPE: sqlite3
GITEA__database__PATH: /data/gitea/gitea.db
GITEA__security__INSTALL_LOCK: "true"
GITEA__server__DOMAIN: 127.0.0.1
GITEA__server__HTTP_PORT: "3000"
GITEA__server__ROOT_URL: http://127.0.0.1:18137/
GITEA__service__DISABLE_REGISTRATION: "false"
GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
volumes:
- gitea_data:/data
- ./dump:/dump-fixture:ro
volumes:
gitea_data:git clone https://github.com/abraxas/gitea-restore-file-open
cd gitea-restore-file-open/lab
./run.shThe proof of concept is written for 127.0.0.1:18137. Do not publish the port off loopback.
What the tree actually consumes
Operator restore. Release asset bytes come from os.Open following the dump symlink. GET the restored download. Witness string in the body.
A few other ways to lose without learning anything:
- Dump-root join escape via an absolute
DownloadURL. That sibling did not land on 1.27.3. - Live migrate
file://LFI. Live migrate setsDownloadFunc. X-Gitea-Internal-Auth. Restore is CLI as the Gitea uid, not/api/internal.- Treating restore HTTP as remote unauth. CVSS is local, high privilege, user interaction (the operator runs restore).
- A reverse shell. Theatre. The witness is
GITEA-FILE-OPEN-WITNESSin the attachment.
Last lab run, trimmed:
restore-rc=0 Restore repo labadmin/restored successfully
download status=200 snippet=GITEA-FILE-OPEN-WITNESS
SUCCESS GITEA-RESTORE-FILE-OPENThe client that produced it is on GitHub.
What this is not
It is not "CVE-2026-59765 never shipped." The HTTP client on this helper is real. file:// never uses it. It is not remote unauth LFI. It is not the dump-root join escape. It is local operator restore of an untrusted dump, Gitea uid os.Open, symlink follow.
The fix
Drop file:// from OpenWithClient, or refuse to follow dump symlinks. Re-run the loopback client against a patched build: restored attachment must not contain the out-of-dump witness. Until then, do not restore untrusted dumps.
I am not going to print a dump you can hand an operator. The os.Open branch is the useful part. If you own the box, run the proof of concept against loopback.
Same product, other unpublished labs on this tag: hostmatcher 0.0.0.0/8 · public-only PAT org · stargazers hidden · follow existence · artifact v4 ReadAll · keys IDOR / git redirect.
References
- Proof of concept: abraxas/gitea-restore-file-open · gitea-restore-file-open-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · dump/ - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-73
- Tree: gitea v1.27.3 ·
uri.go·gitea_uploader.go - Nearby patched: CVE-2026-59765
- SECURITY.md
- Product: Gitea