Research/gitea-restore-file-open
0-dayNo CVEMediumPublic

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

Go
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:

Go
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.

Dockerfile
# Loopback lab image pin for gitea-restore-file-open. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3
YAML
name: 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:
Plain text
git clone https://github.com/abraxas/gitea-restore-file-open
cd gitea-restore-file-open/lab
./run.sh

The 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 sets DownloadFunc.
  • 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-WITNESS in the attachment.

Last lab run, trimmed:

Plain text
restore-rc=0 Restore repo labadmin/restored successfully
download status=200 snippet=GITEA-FILE-OPEN-WITNESS
SUCCESS GITEA-RESTORE-FILE-OPEN

The 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

Gitea, restore-repo file:// os.Open · Abraxas Labs