Research/gitlab-gitaly-fetch-ssrf
0-dayNo CVEHighPublic

GitLab, importer git fetch omits Gitaly resolved_address

GitLab 19.4.1 GitHub Enterprise import skips ImportService IP pin. fetch_as_mirror defaults resolved_address to empty. Gitaly git-fetches the clone hostname again. Attacker objects land in the imported project. No CVE yet.

Name
GitLab, importer git fetch omits Gitaly resolved_address
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
GitLab
Affected
GitLab CE through 19.4.1-ce.0 (v19.4.1-ee tree 26212baacadb); unpublished. GitHub Enterprise / Bitbucket Server importer. Lab image gitlab/gitlab-ce:19.4.1-ce.0.
Published
01 Oct 2026
Updated
01 Oct 2026
Tags
0-day, gitlab, ssrf, gitaly, cwe-918, cwe-367, authenticated

empty pin, second lookup

The proof of concept is on GitHub: abraxas/gitlab-gitaly-fetch-ssrf (loopback client; @abraxas_null). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.

This is GitLab CE 19.4.1 (26212baacadb), GitLab Inc. No CVE yet. CWE-918 / CWE-367. 7.7 High (CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:C/C:H/I:L/A:N). Authenticated GitHub Enterprise import. Direct Transfer needs bulk_import_enabled (self-managed default false). Not unauthenticated.

Same product, siblings: Gitea HTTPS import pin drop, webhook 0.0.0.0/8.

What an attacker can do

Point GitHub Enterprise import at a hostname they control. Rails UrlBlocker.validate! allows a public A-record. ParallelImporter.imports_repository? is true, so ImportService never pins. RepositoryImporter#fetch_as_mirror calls Gitaly with an empty resolved_address. Gitaly git HTTP-fetches whatever that name is now. The imported repo contains the objects.

On all-in-one omnibus that is the worker. Direct http://127.0.0.1 clone hostnames fail closed. Repository-by-URL import already pins.

How I found it

I read the 19.4.1 patch notes, then importers. Projects::ImportService#get_resolved_address rewrites the hostname to the validated IP and passes it into Gitaly. GitHub and Bitbucket Server parallel importers set imports_repository? and skip that path. Direct Transfer's repository_pipeline.rb even calls validate! and discards the return value.

I stood up stock gitlab/gitlab-ce:19.4.1-ce.0. CE import_sources starts empty; the lab enables GitHub. POST /api/v4/import/github with github_hostname=http://ghe.lab:18080. Catcher in the GitLab netns speaks fake GHE JSON plus git http-backend. Seed blob WITNESS is GITLAB-GITALY-FETCH-SSRF-WITNESS.

GET /api/v4/projects/1/repository/files/WITNESS/raw?ref=master returned the witness. Gitaly log: FetchRemote grpc.code=OK on root/ghe-ssrf. Catcher: git/2.55 info/refs and git-upload-pack. Negative github_hostname=http://127.0.0.1:18080 is 400 Invalid URL.

Wrong turns already recorded: seed CveLabRoot9! is a WeakPasswords reject (root substring); 19.4 pads past that check at 64 chars; Faraday cannot connect to a public IP that is not the catcher, so the harness REDIRECTs 1.1.1.1:18080 while UrlBlocker still sees a public A; git-HTTP can win before GET /repos/org/repo flips DNS; GitLab 502s mid-import and then the file is there anyway. A reverse shell. Theatre. The oracle is the imported blob plus Gitaly FetchRemote.

no pin on the way to Gitaly

Plain text
def import_repository
  project.ensure_repository
  refmap = Gitlab::GithubImport.refmap
  project.repository.fetch_as_mirror(project.unsafe_import_url, refmap: refmap, forced: true)

Pass get_resolved_address into fetch_as_mirror the way repository-by-URL import already does.

I am @abraxas_null. Site abraxaslabs.tech. GitHub abraxas. Mail abraxas.null@proton.me.