Security Research Jul 22, 2026

Go Knock on That Door for Me: My Blind CVE Story

A quiet story about CVE-2026-23603, a low-severity blind SSRF in Gitea, where a helpful avatar fetcher would knock on any internal door you pointed it at — and, once in a while, bring a picture back.

Some Bugs Shout. This One Just Shrugged.

Most of the bugs people get excited about are loud.

They leak secrets, dump databases, hand you a shell. They make good screenshots.

This one did almost none of that.

It was polite, obedient, and mostly blind. You could send it to knock on any door you liked, deep inside a network you were never invited into — and then it would come back and refuse to tell you what was said.

That quiet, forgetful little bug became CVE-2026-23603.

The Place Where The Story Begins

The project was Gitea — a lightweight, self-hosted Git service written in Go.

Think of it as your own private GitHub: something a team spins up on a server inside their own network or cloud, so their source code never leaves the building.

That “inside their own network” part matters. Hold onto it.

Like most modern apps, Gitea lets you log in through an external identity provider using OAuth2 / OIDC. You click “Sign in with Company SSO,” your provider vouches for you, and Gitea lets you in.

When it does, your provider hands Gitea a little bundle of facts about you. Your name. Your email. And a picture claim — a URL pointing at your avatar.

Gitea, being helpful, offers to sync that avatar. If the admin turns on this setting:

[oauth2_client]
UPDATE_AVATAR = true

then every time you log in, Gitea reads that picture URL and fetches the image itself, server-side, to save as your profile photo.

That’s the whole feature. Log in, and the server quietly runs off to grab your face.

Meet the Shy Courier

Here’s the homely way I think about what happened next.

Imagine a very obedient, very shy errand-runner.

Point at any door and say “go knock on that one,” and they will. They don’t ask why. They don’t care if it’s the front door of a shop or a door down a private hallway you personally are not allowed to walk into. They just go.

But this courier has one strict rule: they only ever carry pictures.

Hand them a photo at the door, and they bring it back proud as anything. Say something to them instead — a name, a secret, a whole paragraph — and they nod politely, forget it on the walk home, and turn up empty-handed.

Gitea’s avatar fetcher was exactly this courier.

You told it which door to knock on by setting your picture URL. And it would go. Anywhere.

The Tiny Detail

Under the hood, the fetch used Go’s plain, default HTTP client — the moral equivalent of:

resp, err := http.Get(pictureURL) // pictureURL comes straight from the user's claim

No questions asked about where that URL pointed.

So it did not have to be a friendly avatar on the public internet. It could be:

  • http://127.0.0.1:... — a service listening only on loopback, invisible from outside.
  • http://10.0.0.5/... or 192.168.x.x — something on the internal network.
  • http://169.254.169.254/latest/meta-data/ — the cloud metadata endpoint on AWS, GCP, or Azure. The one that sometimes hands out credentials to anyone standing close enough.

There’s a bonus subtlety, too: Go’s client follows redirects by default. So even a perfectly innocent, public avatar URL could quietly 302 the server toward an internal target. The door you knock on isn’t always the door that answers.

Proving it needed no clever exploit chain and no theatrics. I was a normal, low-privileged user — I didn’t need admin, just control over my own OIDC profile, which by definition I have.

So I stood up a little fake OIDC provider, pointed my picture claim at an internal address, and logged in.

# on the internal side, just listen
nc -lvnp 8080
# then log in with UPDATE_AVATAR on;
# Gitea's server-side knock shows up right here

The knock arrived — from Gitea’s own network position, not mine. The vulnerable code lived in routers/web/auth/oauth.go, in a function named oauth2UpdateAvatarIfNeed, reached through oauth2SignInSync over in oauth_signin_sync.go.

The Sibling Door That Was Guarded

Here’s the part that made me tilt my head.

Gitea already knew this was dangerous.

It has other features that fetch things from URLs — webhooks and repository migrations. Both already route their outbound requests through a doorman called hostmatcher.NewDialContext(...), which refuses to connect to loopback, private ranges, and link-local addresses.

Someone had clearly thought about this. Someone had built the guard.

They just never put it on the avatar door.

Two sibling features, standing right next to each other. One checked the address before it stepped outside. The other happily wandered off to 169.254.169.254, whistling.

This is the thing I keep running into. So many bugs don’t live inside a system. They live in the gap between two systems — one layer assumes a protection is there, another never got it, and the seam between them is exactly where the trouble slips in.

One door guarded, its twin wide open. Same house.

Why It Comes Back Empty (Mostly)

Now the blindness.

When Gitea gets the response, it tries to treat it as an image. If the bytes aren’t a valid image — HTML, JSON, a plaintext error, a metadata document — Gitea shrugs and throws it away.

The shy courier, forgetting everything that wasn’t a picture.

That’s what makes this a blind SSRF. You can absolutely make the server knock on internal doors. You just rarely get to hear what they said back. Most of the reconnaissance is inference: did the knock connect quickly, connect slowly, or not at all?

The One Time the Courier Comes Home Grinning

But remember the courier’s one rule. They only carry pictures.

So what happens if an internal door hands back a real, valid image — small enough to fit under Gitea’s size limit?

Gitea keeps it. And saves it. As your avatar.

Your profile picture, on the target’s own Gitea, quietly becomes a photo fetched from somewhere inside their network you were never supposed to reach.

It’s a narrow trick — a “limited response-retrieval primitive,” in the formal words. The stars have to line up: the internal endpoint has to serve a real image, of the right size, at the URL you guessed.

But when it lines up, it’s genuinely funny. The blind bug opens its eyes for exactly one frame, and the trophy it brings home is a picture — hung, of all places, next to your username.

How Small Was It, Honestly?

I want to be straight about the size of this, because honesty is the whole job.

It’s rated Low severity, and it deserves to be.

The classification is CWE-918: Server-Side Request Forgery, with this CVSS 3.1 vector:

CVSS:3.1/AV:N/AC:H/PR:L/UI:N/S:U/C:L/I:N/A:N

Reachable over the network, yes — but with high attack complexity (you need OIDC login, avatar sync enabled, and a controllable provider), only low privileges, and only a low hit to confidentiality. No integrity damage. No availability damage.

It’s not a cluster-ender. It’s a helpful server being a little too helpful, and a doorman who forgot to cover one door. That’s allowed to be the whole story. Not every finding is a headline.

Responsible Disclosure

After confirming the behavior, I reported it through GitHub’s Security Advisory process.

It was published as GHSA-x77v-q46j-393g on July 13, 2026, as CVE-2026-23603.

  • Affected: Gitea <= 1.26.4
  • Patched: Gitea 1.27.0 — the fix puts the avatar fetch behind the same host restrictions the webhook and migration paths already used. The missing doorman, finally hired.
  • Workaround if you can’t upgrade yet: set [oauth2_client] UPDATE_AVATAR = false, which removes the vulnerable path.

And here’s the charming bit: I wasn’t the only one who noticed.

The advisory credits several of us — alimezar, Vext-Labs, theluckystrike, prakhar0x01, myself, and khoadb175 — who each reported this independently. Turns out an open, guardless door catches more than one pair of eyes at the same time.

There’s something oddly reassuring about that. A little crowd of strangers, all quietly filing the same polite report, all trying to help the same project sand down the same sharp edge.

The Real Lesson

If your application ever takes a URL from a user and goes and fetches it, you’ve quietly signed up to be someone’s errand-runner.

And the important question isn’t “is this input scary?”

It’s “where does this errand actually go, and is there a doorman on that path?”

Because the guard already existed here. Somebody built the doorman. They just didn’t walk him over to the door next door.

Final Thought

The thing I keep coming back to isn’t the metadata endpoint or the redirect trick.

It’s how quietly a defense you already trust can stop one door short.

So I keep asking the small, boring, powerful question:

“This thing fetches a URL. Does anyone check where it’s actually going?”

Sometimes the answer is yes, everywhere.

And sometimes it’s yes, everywhere — except the one shy little courier who only ever remembered to bring back a picture.