From c242d6ac575d790a4f6e30772b20b47079be2a1a Mon Sep 17 00:00:00 2001 From: "Chris (ChrisJr404)" <11917633+ChrisJr404@users.noreply.github.com> Date: Fri, 1 May 2026 18:28:13 -0400 Subject: [PATCH] gobusterfuzz: preserve percent-encoded sequences in wordlist words When the user provides a wordlist that contains percent-encoded payloads (e.g. %2e%2e/etc/passwd for path-traversal fuzzing), gobuster fuzz currently double-encodes them on the wire because the FUZZ substitution writes raw bytes into url.Path which is treated as the decoded form by net/url. The % sign in the substituted Path then gets re-escaped to %25 by url.String(), producing requests like /cgi-bin/%252e%252e/etc/passwd that the target sees as the literal string "%2e%2e", not "..". Substitute FUZZ in url.EscapedPath() (which preserves an already-set RawPath) and re-assign through a small helper that puts the encoded form in RawPath and the decoded form in Path. url.String() then uses RawPath verbatim, matching what the user wrote and what curl --path-as-is or wfuzz would send. Adds a SetURLPathPreservingEncoding helper in libgobuster with table-driven tests covering percent-encoded inputs, plain paths, paths with a pre-existing encoded segment in the user URL, and inputs with an invalid percent sequence (which falls back to the previous behavior). Fixes #618 --- gobusterfuzz/gobusterfuzz.go | 6 +++- libgobuster/helpers.go | 19 +++++++++++ libgobuster/helpers_test.go | 66 ++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/gobusterfuzz/gobusterfuzz.go b/gobusterfuzz/gobusterfuzz.go index a5cb028f..d87667dd 100644 --- a/gobusterfuzz/gobusterfuzz.go +++ b/gobusterfuzz/gobusterfuzz.go @@ -95,7 +95,11 @@ func (d *GobusterFuzz) ProcessWord(ctx context.Context, word string, progress *l url := *d.options.URL url.Fragment = strings.ReplaceAll(url.Fragment, FuzzKeyword, word) url.Host = strings.ReplaceAll(url.Host, FuzzKeyword, word) - url.Path = strings.ReplaceAll(url.Path, FuzzKeyword, word) + // Replace FUZZ in the encoded path string (EscapedPath returns RawPath + // when set, otherwise the percent-encoded form of Path) and re-assign + // via the helper so percent sequences in the wordlist word are not + // double-encoded as %25XX. See issue #618. + libgobuster.SetURLPathPreservingEncoding(&url, strings.ReplaceAll(url.EscapedPath(), FuzzKeyword, word)) url.Scheme = strings.ReplaceAll(url.Scheme, FuzzKeyword, word) query := url.Query() diff --git a/libgobuster/helpers.go b/libgobuster/helpers.go index 4acd82f3..c7cc3941 100644 --- a/libgobuster/helpers.go +++ b/libgobuster/helpers.go @@ -6,12 +6,31 @@ import ( "errors" "fmt" "io" + "net/url" "os" "regexp" "strconv" "strings" ) +// SetURLPathPreservingEncoding assigns rawPath to u as both the encoded +// (RawPath) and decoded (Path) forms. When u.String() serializes the URL it +// emits RawPath verbatim, which preserves percent-encoded sequences in +// rawPath instead of double-encoding them (e.g. %2e -> %252e). This matters +// for fuzzing wordlists that contain pre-encoded payloads. If rawPath is not +// a valid percent-encoded path the function falls back to the previous +// behavior of assigning it to Path only. +func SetURLPathPreservingEncoding(u *url.URL, rawPath string) { + decoded, err := url.PathUnescape(rawPath) + if err != nil { + u.Path = rawPath + u.RawPath = "" + return + } + u.Path = decoded + u.RawPath = rawPath +} + // Set is a set of Ts type Set[T comparable] struct { Set map[T]bool diff --git a/libgobuster/helpers_test.go b/libgobuster/helpers_test.go index f2f39d04..30d402bd 100644 --- a/libgobuster/helpers_test.go +++ b/libgobuster/helpers_test.go @@ -3,6 +3,7 @@ package libgobuster import ( "errors" "io" + "net/url" "os" "reflect" "strconv" @@ -11,6 +12,71 @@ import ( "testing/iotest" ) +func TestSetURLPathPreservingEncoding(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + startURL string + rawPath string + wantURL string + wantPath string + wantRaw string + }{ + { + name: "percent_encoded_word_is_not_double_encoded", + startURL: "http://example/cgi-bin/FUZZ", + rawPath: "/cgi-bin/%2e%2e/opt/passwords", + wantURL: "http://example/cgi-bin/%2e%2e/opt/passwords", + wantPath: "/cgi-bin/../opt/passwords", + wantRaw: "/cgi-bin/%2e%2e/opt/passwords", + }, + { + name: "plain_path_is_unchanged_on_the_wire", + startURL: "http://example/", + rawPath: "/admin/login", + wantURL: "http://example/admin/login", + wantPath: "/admin/login", + wantRaw: "/admin/login", + }, + { + name: "preexisting_encoded_segment_round_trips", + startURL: "http://example/dir%20space/FUZZ", + rawPath: "/dir%20space/admin", + wantURL: "http://example/dir%20space/admin", + wantPath: "/dir space/admin", + wantRaw: "/dir%20space/admin", + }, + { + name: "invalid_percent_sequence_falls_back_to_path_only", + startURL: "http://example/", + rawPath: "/literal-%-sign", + wantURL: "http://example/literal-%25-sign", + wantPath: "/literal-%-sign", + wantRaw: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + u, err := url.Parse(tc.startURL) + if err != nil { + t.Fatalf("parse: %v", err) + } + SetURLPathPreservingEncoding(u, tc.rawPath) + if got := u.String(); got != tc.wantURL { + t.Errorf("u.String() = %q, want %q", got, tc.wantURL) + } + if u.Path != tc.wantPath { + t.Errorf("u.Path = %q, want %q", u.Path, tc.wantPath) + } + if u.RawPath != tc.wantRaw { + t.Errorf("u.RawPath = %q, want %q", u.RawPath, tc.wantRaw) + } + }) + } +} + func TestNewSet(t *testing.T) { t.Parallel() if NewSet[string]().Set == nil {