Research/gitea-hostmatcher-0000-ssrf
0-dayNo CVEHighPublic

Gitea, authenticated hostmatcher 0.0.0.0/8 SSRF

Gitea 1.27.3 reservedIPNets still omits RFC 6890 0.0.0.0/8. A signed-in user can webhook http://0.0.0.1 and read the HTTP body from hook history. 127.0.0.1 on the same port is denied. No CVE yet.

Name
Gitea, authenticated hostmatcher 0.0.0.0/8 SSRF
Type
0-day analysis
CVE
n/a
CVE Risk
high
Disclosure Status
public
Vendor
Gitea
Affected
Gitea through v1.27.3 (146cc3e); unpublished leftover of CVE-2026-22874. Needs a user who can create a repo webhook. Default open registration.
Published
29 Sept 2026
Updated
29 Sept 2026
Tags
0-day, gitea, ssrf, webhook, cwe-918, authenticated

0.0.0.1 is not loopback

I am @abraxas_null. The proof of concept is on GitHub: abraxas/gitea-hostmatcher-0000-ssrf (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, oracle/server.py. Authorized lab only. It talks to loopback.

This is Gitea v1.27.3 (146cc3e). No CVE yet. CWE-918. 7.7 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:N/A:N).

How I found it

v1.27.3 is the build that closed the 2026 GHSA wave: CVE-2026-60004 (diffpatch hook RCE, CISA KEV), CVE-2026-59774 (unauth Org-mode #+INCLUDE), the fork-PR Actions gates, the reverse-proxy X-WEBAUTH-USER default, and a pile of scope and visibility bugs. Stay on 1.27.3+ for those. Do not set REVERSE_PROXY_TRUSTED_PROXIES = *.

I sat on that tag anyway. Patches that add an allow-list or a deny-list are a gift: you read the list, then you ask what IANA still has that the list forgot. Two other bugs already had labs on this tree (keys IDOR and git-redirect SSRF). That post mentioned a webhook to http://0.0.0.1/. This is that one.

CVE-2026-22874 added reservedIPNets. CGNAT, TEST-NET, NAT64, Teredo, Azure WireServer. Real ranges. Not RFC 6890 0.0.0.0/8, the old "this network" block. IANA still publishes it. Go IsGlobalUnicast is true for 0.0.0.1. It is not IsLoopback (127.0.0.0/8). It is not IsPrivate. The default external allow-list treats it as the public internet.

Linux will deliver 0.0.0.1 to a local socket if that address is on lo. Same listener, two names. 127.0.0.1 denied. 0.0.0.1 delivered. That is the whole trick.

Webhook delivery stores the HTTP body in hook history. That is a better oracle than git clone, which is mostly "did the fetch work." Full-response SSRF. I put an oracle in Gitea's netns on port 8080, added 0.0.0.1/8 on lo with NET_ADMIN, and created two webhooks. Negative control first.

Wrong turns already recorded: treating webhook create HTTP 201 as the oracle (history is the oracle); 127.0.0.1 delivery succeeding (that would be a different bug - lab requires it denied); hitting /api/internal with X-Gitea-Internal-Auth; a reverse shell. Theatre. The witness is GITEA-0000-SSRF in hook history.

The missing CIDR

Go
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
	var nets []*net.IPNet
	for _, cidr := range []string{
		"100.64.0.0/10",    // RFC 6598 Carrier-Grade NAT
		"168.63.129.16/32", // Azure WireServer metadata endpoint
		"192.0.0.0/24",     // RFC 6890 IETF protocol assignments
		// ... TEST-NET, NAT64, Teredo, 6to4 ...
	} {

No 0.0.0.0/8. Then matchesIP for external:

Go
case MatchBuiltinExternal:
	if ip.IsGlobalUnicast() && !isReservedIP(ip) && !ip.IsPrivate() {
		return true
	}
case MatchBuiltinLoopback:
	if ip.IsLoopback() {
		return true
	}

0.0.0.1 takes the first branch. I am not printing a webhook JSON you can paste at someone else's /hooks. The missing CIDR is the useful part.

What an attacker can do

Register (default open registration), create a repo, point a webhook at http://0.0.0.1:<local-port>/, test it, and read the response body from hook history. That is loopback HTTP that 127.0.0.1 on the same port cannot reach: anything listening in Gitea's netns that bound more broadly than loopback, including services the operator thought were "localhost only" because they blocked 127.0.0.0/8.

It does not set X-Gitea-Internal-Auth. It is not RCE by itself. It is a full HTTP body from an address the allow-list still calls external.

The lab (run this at home)

Source of truth is lab/. Image gitea/gitea:1.27.3. Port 18131. Bind it to loopback. The oracle shares Gitea's netns on port 8080. run.sh adds 0.0.0.1/8 on lo (NET_ADMIN). Direct 127.0.0.1 on that port must stay blocked.

Dockerfile
# Loopback lab image pin for gitea-hostmatcher-0000-ssrf. Full stack: docker-compose.yml
FROM gitea/gitea:1.27.3
YAML
name: gitea-hostmatcher-0000-ssrf

services:
  gitea:
    build: .
    image: gitea-hostmatcher-0000-ssrf:lab
    ports:
      - "127.0.0.1:18131:3000"
    cap_add:
      - NET_ADMIN
    environment:
      USER_UID: "1000"
      USER_GID: "1000"
      GITEA__database__DB_TYPE: sqlite3
      GITEA__database__PATH: /data/gitea/gitea.db
      GITEA__security__INSTALL_LOCK: "true"
      GITEA__server__DOMAIN: 127.0.0.1
      GITEA__server__HTTP_PORT: "3000"
      GITEA__server__ROOT_URL: http://127.0.0.1:18131/
      GITEA__service__DISABLE_REGISTRATION: "false"
      GITEA__service__REQUIRE_SIGNIN_VIEW: "false"
    volumes:
      - gitea_data:/data

  oracle:
    image: python:3.12-alpine
    network_mode: service:gitea
    depends_on:
      - gitea
    volumes:
      - ./oracle/server.py:/server.py:ro
    command: ["python3", "/server.py"]

volumes:
  gitea_data:
Plain text
git clone https://github.com/abraxas/gitea-hostmatcher-0000-ssrf
cd gitea-hostmatcher-0000-ssrf/lab
./run.sh

The proof of concept is written for 127.0.0.1:18131. Do not publish the port off loopback.

What the tree actually consumes

Create a webhook. Test it. Read history. Negative control first: 127.0.0.1:8080 must not store the witness. Hit: 0.0.0.1:8080 must.

A few other ways to lose without learning anything:

  • Treating webhook create HTTP 201 as the oracle. History is the oracle.
  • 127.0.0.1 delivery succeeding. That would be a different bug. Lab requires it denied.
  • Hitting /api/internal with X-Gitea-Internal-Auth. Webhook delivery will not set that header.
  • A reverse shell. Theatre. The witness is GITEA-0000-SSRF in hook history.

Last lab run, trimmed:

Plain text
create-hook name=neg-loopback status=201
negative-control 127.0.0.1 blocked
create-hook name=hit-0000 status=201
poll id=2 witness=True
SUCCESS GITEA-HOSTMATCHER-0000

The client that produced it is on GitHub.

What this is not

It is not "CVE-2026-22874 never shipped." The reserved list is real. It missed this /8. It is not unauthenticated. It is not RCE. It is a full HTTP body from an address the allow-list still calls external.

The fix

Add 0.0.0.0/8 to reservedIPNets, or stop treating IsGlobalUnicast as "safe to dial." Re-run the loopback client against a patched build: 0.0.0.1 must be denied the same way 127.0.0.1 is.

I am not going to print a hook URL you can paste at someone else's instance. The missing CIDR is the useful part. If you own the box, run the proof of concept against loopback.

Same product, other unpublished labs on this tag: public-only PAT org · stargazers hidden · follow existence · artifact v4 ReadAll · restore file:// · keys IDOR / git redirect.

References