Proof-of-concept for a Gitea restore-repo file:// local file inclusion (CWE-73) that copies symlink targets outside an untrusted dump into release attachments.
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · gitea-restore-file-open
Gitea 1.27.3 - Gitea
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.
Operator restore of an untrusted dump copies the symlink target into a release attachment. As the Gitea uid, that is any file the service account can read.
| ID | no CVE yet |
| CWE | CWE-73 |
| CVSS | Medium: 4.2 CVSS:3.1/AV:L/AC:L/PR:H/UI:R/S:U/C:H/I:N/A:N |
| Product | Gitea |
| Affected | through v1.27.3 (146cc3e); local operator gitea restore-repo |
| Auth | local operator restore of an untrusted dump |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1 only |
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.
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. Live migrate from GitHub/GitLab/Gitea sets DownloadFunc and does not os.Open an attacker file://.
Two restore maps sat on the leftover table. I labbed the obvious one first.
Finding 8 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. That is not join-escape on this tag.
Finding 9 is what landed. 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.
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.
cd lab
./run.sh
Target only http://127.0.0.1:18137. Compose mounts lab/dump read-only. run.sh plants the witness inside the container, builds the dump, puts the symlink, then gitea restore-repo.
restore-rc=0 Restore repo labadmin/restored successfully
download status=200 snippet=GITEA-FILE-OPEN-WITNESS
SUCCESS GITEA-RESTORE-FILE-OPEN
Drop file:// from OpenWithClient, or refuse to follow dump symlinks, and do not restore untrusted dumps. Restored attachment must not contain the out-of-dump witness.
uri.go · gitea_uploader.go · FilePathJoinAbsGNU Affero GPL v3.0. See LICENSE. Loopback lab only. No warranty.