├── .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 |
--------------------------------------------------------------------------------