├── .gitignore ├── .idea ├── .gitignore ├── modules.xml └── navgix.iml ├── go.mod ├── README.md └── navgix.go /.gitignore: -------------------------------------------------------------------------------- 1 | go.sum 2 | navgix 3 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/navgix.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module navgix 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.8.1 7 | github.com/cristalhq/acmd v0.11.1 8 | github.com/felixge/fgprof v0.9.3 9 | golang.org/x/term v0.5.0 10 | ) 11 | 12 | require ( 13 | github.com/andybalholm/cascadia v1.3.1 // indirect 14 | github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect 15 | golang.org/x/net v0.7.0 // indirect 16 | golang.org/x/sys v0.5.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # navgix 2 | 3 | navgix is a multi-threaded golang tool that will check for nginx alias traversal vulnerabilities 4 | 5 | ## Techniques 6 | Currently, navgix supports 2 techniques for finding vulnerable directories (or location aliases). Those being the following: 7 | 8 | ### Heuristics 9 | navgix will make an initial GET request to the page, and if there are any directories specified on the page HTML (specified in src attributes on html components), it will test each folder in the path for the vulnerability, therefore if it finds a link to /static/img/photos/avatar.png, it will test /static/, /static/img/ and /static/img/photos/. 10 | 11 | ### Brute-force 12 | navgix will also test for a short list of common directories that are common to have this vulnerability and if any of these directories exist, it will also attempt to confirm if a vulnerability is present. 13 | 14 | ## Installation 15 | ``` 16 | git clone https://github.com/Hakai-Offsec/navgix; cd navgix; 17 | go build 18 | ``` 19 | 20 | ## Acknowledgements 21 | 22 | - [Orange Tsai - Breaking Parser Logic! (2018)](https://i.blackhat.com/us-18/Wed-August-8/us-18-Orange-Tsai-Breaking-Parser-Logic-Take-Your-Path-Normalization-Off-And-Pop-0days-Out-2.pdf) 23 | - [bayotop/off-by-slash](https://github.com/bayotop/off-by-slash) 24 | 25 | -------------------------------------------------------------------------------- /navgix.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "crypto/tls" 7 | "flag" 8 | "fmt" 9 | "github.com/PuerkitoBio/goquery" 10 | "github.com/cristalhq/acmd" 11 | "io/ioutil" 12 | "log" 13 | "math/rand" 14 | "net/http" 15 | _ "net/http/pprof" 16 | "net/url" 17 | "os" 18 | "os/signal" 19 | "runtime/pprof" 20 | "strconv" 21 | "strings" 22 | "sync" 23 | "time" 24 | ) 25 | 26 | var concurrentSites = 5 27 | var threads = 5 28 | 29 | func RandomString(length int) string { 30 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 31 | b := make([]rune, length) 32 | for i := range b { 33 | b[i] = letters[rand.Intn(len(letters))] 34 | } 35 | return string(b) 36 | } 37 | 38 | func Log(msg string, a ...interface{}) { 39 | timestamp := time.Now().Format("2006-01-02_15:04:05.000000000") 40 | 41 | fmt.Printf("[navgix "+timestamp+"] "+msg+"\n", a...) 42 | } 43 | 44 | func InSlice(slice []string, word string) bool { 45 | for _, element := range slice { 46 | if element == word { 47 | return true 48 | } 49 | } 50 | return false 51 | } 52 | 53 | func MakeGET(url string) (string, int, string) { 54 | client := &http.Client{ 55 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 56 | return http.ErrUseLastResponse 57 | }, 58 | Timeout: 8 * time.Second, 59 | } 60 | 61 | resp, err := client.Get(url) 62 | if err != nil { 63 | return "Error: " + err.Error(), 9999, "" 64 | 65 | } 66 | defer resp.Body.Close() 67 | 68 | // read response to string 69 | body, err := ioutil.ReadAll(resp.Body) 70 | if err != nil { 71 | fmt.Println(err) 72 | } 73 | location := resp.Header.Get("Location") 74 | 75 | return string(body), resp.StatusCode, location 76 | } 77 | 78 | func MakeGETRetry(url string) (string, int, string) { 79 | retry := 2 80 | for i := 0; i < retry; i++ { 81 | body, status, location := MakeGET(url) 82 | if status != 9999 { 83 | return body, status, location 84 | } 85 | } 86 | return "", 9999, "" 87 | } 88 | 89 | func CheckFolderForTraversal(url string, folder string) bool { 90 | _, statusCurrent, locationCurrent := MakeGETRetry(url + folder + ".") 91 | 92 | if statusCurrent == 404 { 93 | return false 94 | } 95 | 96 | if statusCurrent == 301 || statusCurrent == 302 { 97 | if strings.HasSuffix(locationCurrent, folder+"./") { 98 | _, statusTraversal, locationTraversal := MakeGETRetry(url + folder + "..") 99 | if statusTraversal == 301 || statusTraversal == 302 { 100 | if strings.HasSuffix(locationTraversal, folder+"../") { 101 | respNotFound, statusNotFound, _ := MakeGETRetry(url + folder + "." + RandomString(4)) 102 | if statusNotFound == 404 || strings.Contains(strings.ToLower(respNotFound), "not found") { 103 | respNotFound2, statusNotFound2, _ := MakeGETRetry(url + folder + "z") 104 | if statusNotFound2 == 404 || strings.Contains(strings.ToLower(respNotFound2), "not found") { 105 | // vulnerable 106 | 107 | _, statusNotFound3, _ := MakeGETRetry(url + folder + "z..") 108 | if statusNotFound3 != 302 && statusNotFound3 != 301 { 109 | // vulnerable 110 | Log("Vulnerable: %s", url+folder+"../") 111 | return true 112 | } 113 | 114 | } 115 | 116 | } 117 | } 118 | 119 | } 120 | } 121 | 122 | } 123 | return false 124 | } 125 | 126 | func CheckFoldersForTraversal(url string, folders []string) { 127 | var wg sync.WaitGroup 128 | semaphore := make(chan struct{}, threads) 129 | 130 | // Use a bounded semaphore with a capacity of 'threads' 131 | for i := 0; i < threads; i++ { 132 | semaphore <- struct{}{} 133 | } 134 | 135 | for _, word := range folders { 136 | wg.Add(1) 137 | // Acquire a token from the semaphore channel 138 | <-semaphore 139 | go func(word string) { 140 | CheckFolderForTraversal(url, word) 141 | // Release the token back to the semaphore channel 142 | semaphore <- struct{}{} 143 | wg.Done() 144 | }(word) 145 | } 146 | 147 | wg.Wait() 148 | } 149 | func MakeFolderEndpointsFromPath(path string) []string { 150 | // remove query string 151 | // "/img/media/a.jpg" -> ["img", "img/media"] 152 | var endpoints []string 153 | var endpoint string 154 | 155 | // check if begins with https:// or http:// or // 156 | if strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "//") { 157 | // get path 158 | u, err := url.Parse(path) 159 | if err != nil { 160 | log.Fatal(err) 161 | } 162 | path = u.Path 163 | if strings.HasPrefix(path, "/") { 164 | path = path[1:] 165 | } 166 | 167 | } 168 | 169 | for _, word := range strings.Split(path, "/") { 170 | if word != "" { 171 | // check if last 172 | if word == strings.Split(path, "/")[len(strings.Split(path, "/"))-1] { 173 | break 174 | } 175 | // remove query string 176 | if strings.Contains(word, "?") { 177 | word = strings.Split(word, "?")[0] 178 | } 179 | 180 | endpoint = endpoint + word + "/" 181 | endpointNoSlash := strings.TrimSuffix(endpoint, "/") 182 | if !InSlice(endpoints, endpointNoSlash) { 183 | endpoints = append(endpoints, endpointNoSlash) 184 | } 185 | 186 | } 187 | 188 | } 189 | return endpoints 190 | 191 | } 192 | 193 | func findEndpoints(url string) []string { 194 | html, _, _ := MakeGETRetry(url) 195 | 196 | doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) 197 | if err != nil { 198 | log.Fatal(err) 199 | } 200 | var foundEndpoints []string 201 | doc.Find("*").Each(func(i int, s *goquery.Selection) { 202 | if src, exists := s.Attr("src"); exists { 203 | for _, endpoint := range foundEndpoints { 204 | //check if endpoint already exists 205 | if endpoint == src { 206 | return 207 | } 208 | } 209 | foundEndpoints = append(foundEndpoints, src) 210 | 211 | } 212 | }) 213 | finalDirectoryEndpoints := []string{} 214 | for _, endpoint := range foundEndpoints { 215 | directoryEndpoints := MakeFolderEndpointsFromPath(endpoint) 216 | for _, directoryEndpoint := range directoryEndpoints { 217 | if !InSlice(finalDirectoryEndpoints, directoryEndpoint) { 218 | finalDirectoryEndpoints = append(finalDirectoryEndpoints, directoryEndpoint) 219 | } 220 | } 221 | } 222 | 223 | return finalDirectoryEndpoints 224 | 225 | } 226 | 227 | func CheckTarget(url string) { 228 | if url[len(url)-1:] != "/" { 229 | url = url + "/" 230 | } 231 | // Check for alias traversal vulnerability (bruteforce) 232 | var dictionary = []string{ 233 | "static", 234 | "js", 235 | "images", 236 | "img", 237 | "css", 238 | "assets", 239 | "media", 240 | "lib", 241 | } 242 | CheckFoldersForTraversal(url, dictionary) 243 | // Check for alias traversal vulnerability (endpoint finding) 244 | CheckFoldersForTraversal(url, findEndpoints(url)) 245 | // Check for directory listing 246 | // Check for file existence 247 | // Check for file contents 248 | } 249 | 250 | func main() { 251 | c := make(chan os.Signal, 1) 252 | signal.Notify(c, os.Interrupt) 253 | go func() { 254 | for _ = range c { 255 | os.Exit(1) 256 | } 257 | }() 258 | 259 | http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 260 | cmds := []acmd.Command{ 261 | { 262 | Name: "scan", 263 | Description: "Scans URL(s) for the nginx alias traversal vulnerability", 264 | ExecFunc: func(ctx context.Context, args []string) error { 265 | URLInput := "" 266 | fs := flag.NewFlagSet("navgix scan", flag.ContinueOnError) 267 | fs.StringVar(&URLInput, "u", "", "Single URL or file containing URLs to scan") 268 | fs.IntVar(&threads, "t", 10, "Number of threads to use for each site") 269 | fs.IntVar(&concurrentSites, "c", 20, "Number of concurrent sites to scan") 270 | 271 | if err := fs.Parse(args); err != nil { 272 | return err 273 | } 274 | 275 | if len(args) == 0 { 276 | fs.Usage() 277 | return nil 278 | } 279 | 280 | if URLInput == "" { 281 | fs.Usage() 282 | fmt.Println("Error: No URL(s) specified") 283 | return nil 284 | } 285 | 286 | // check if file 287 | if _, err := os.Stat(URLInput); err == nil { 288 | // file exists 289 | file, err := os.Open(URLInput) 290 | if err != nil { 291 | log.Fatal(err) 292 | } 293 | defer file.Close() 294 | 295 | scanner := bufio.NewScanner(file) 296 | targets := []string{} 297 | for scanner.Scan() { 298 | targets = append(targets, scanner.Text()) 299 | } 300 | 301 | if err := scanner.Err(); err != nil { 302 | log.Fatal(err) 303 | } 304 | var wg sync.WaitGroup 305 | semaphore := make(chan struct{}, threads) 306 | 307 | // Use a bounded semaphore with a capacity of 'threads' 308 | for i := 0; i < threads; i++ { 309 | semaphore <- struct{}{} 310 | } 311 | Log("starting scan on " + strconv.Itoa(len(targets)) + " targets") 312 | 313 | for _, target := range targets { 314 | wg.Add(1) 315 | // Acquire a token from the semaphore channel 316 | <-semaphore 317 | go func(word string) { 318 | CheckTarget(word) 319 | // Release the token back to the semaphore channel 320 | semaphore <- struct{}{} 321 | wg.Done() 322 | }(target) 323 | } 324 | 325 | wg.Wait() 326 | pprof.StopCPUProfile() 327 | 328 | return nil 329 | } else { 330 | Log("starting scan on " + URLInput) 331 | CheckTarget(URLInput) 332 | } 333 | 334 | return nil 335 | }, 336 | }, 337 | } 338 | 339 | r := acmd.RunnerOf(cmds, acmd.Config{ 340 | AppName: "navgix", 341 | AppDescription: "Navgix is a tool for mass scanning URLs for the nginx alias traversal vulnerability.", 342 | Version: "0.0.1", 343 | }) 344 | 345 | if err := r.Run(); err != nil { 346 | r.Exit(err) 347 | } 348 | } 349 | --------------------------------------------------------------------------------