├── VERSION ├── docker-push.sh ├── todo.txt ├── go.mod ├── .gitignore ├── create-releases.sh ├── Dockerfile ├── LICENSE ├── go.sum ├── version.go ├── README.md ├── main_test.go └── main.go /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.0 -------------------------------------------------------------------------------- /docker-push.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # get the current version of the tool from `./VERSION` 4 | VERSION=$(cat VERSION) 5 | 6 | docker buildx build --platform linux/amd64,linux/arm64 --push . -t fw10/sessionprobe:$VERSION -t fw10/sessionprobe:latest -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | * Make this a Burp extension?! 2 | 3 | * Support not only GET but POST/PATCH/PUT/DELETE -> Not sure how though yet 4 | 5 | * Feature to find potentially interesting outliers where response code is not 200 6 | 7 | * Allow parsing in a swagger file instead of the urls file 8 | 9 | * Automatically exclude length 0 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module sessionprobe 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/fatih/color v1.15.0 7 | github.com/spf13/cobra v1.7.0 8 | ) 9 | 10 | require ( 11 | github.com/hashicorp/go-version v1.6.0 // indirect 12 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 13 | github.com/mattn/go-colorable v0.1.13 // indirect 14 | github.com/mattn/go-isatty v0.0.17 // indirect 15 | github.com/spf13/pflag v1.0.5 // indirect 16 | golang.org/x/sys v0.6.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | sessionprobe* 24 | urls.txt 25 | output.txt 26 | releases 27 | 28 | .DS_Store 29 | unauthenticated.txt 30 | bearer_token*.txt 31 | 32 | testing 33 | 34 | # Avoid committing any of my tests 35 | *.txt -------------------------------------------------------------------------------- /create-releases.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # get the current version of the tool from `./VERSION` 4 | VERSION=$(cat VERSION) 5 | 6 | FLAGS="-X main.AppVersion=$VERSION -s -w" 7 | 8 | rm -rf releases 9 | mkdir -p releases 10 | 11 | # build for Windows 12 | GOOS=windows GOARCH=amd64 go build -ldflags="$FLAGS" -trimpath 13 | mv sessionprobe.exe releases/sessionprobe-windows-amd64.exe 14 | 15 | # build for M1 Macs (arm64) 16 | GOOS=darwin GOARCH=arm64 go build -ldflags="$FLAGS" -trimpath 17 | mv sessionprobe releases/sessionprobe-mac-arm64 18 | 19 | # build for Intel Macs (amd64) 20 | GOOS=darwin GOARCH=amd64 go build -ldflags="$FLAGS" -trimpath 21 | mv sessionprobe releases/sessionprobe-mac-amd64 22 | 23 | # build for x64 Linux (amd64) 24 | GOOS=linux GOARCH=amd64 go build -ldflags="$FLAGS" -trimpath 25 | mv sessionprobe releases/sessionprobe-linux-amd64 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # First stage of multi-stage build: build the Go binary 2 | FROM golang:1.19-alpine AS builder 3 | 4 | # Create directory for build context 5 | WORKDIR /build 6 | 7 | # Get the required files 8 | COPY go.mod . 9 | COPY go.sum . 10 | COPY *.go ./ 11 | COPY VERSION . 12 | 13 | # Download all dependencies 14 | RUN go mod download 15 | 16 | # Build the Go app 17 | RUN CGO_ENABLED=0 go build -ldflags="-X main.AppVersion=$(cat VERSION) -s -w" -trimpath -o sessionprobe . 18 | 19 | # Second stage of multi-stage build: run the Go binary 20 | FROM alpine:latest 21 | 22 | # Create required directories and adjust working directory 23 | RUN mkdir /app /app/files 24 | WORKDIR /app/files 25 | 26 | # Running as a non-root user 27 | RUN adduser -D local 28 | USER local 29 | 30 | # Copy binary from first stage 31 | COPY --from=builder /build/sessionprobe /app 32 | 33 | # This command runs the app 34 | ENTRYPOINT ["/app/sessionprobe"] 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Florian Walter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 2 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 3 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 4 | github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= 5 | github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 6 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 7 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 8 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 9 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 10 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 11 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 12 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 13 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 14 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 15 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 16 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 17 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 18 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 19 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 20 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 21 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 22 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 23 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/fatih/color" 11 | "github.com/hashicorp/go-version" 12 | ) 13 | 14 | 15 | var AppVersion string = "0.0.0" 16 | var latestRelease string = "https://github.com/dub-flow/sessionprobe/releases/latest" 17 | 18 | func NotifyOfUpdates() { 19 | client := &http.Client{} 20 | req, err := http.NewRequest("GET", latestRelease, nil) 21 | if err != nil { 22 | return 23 | } 24 | 25 | req.Header.Add("Accept", "application/json") 26 | 27 | resp, err := client.Do(req) 28 | if err != nil { 29 | return 30 | } 31 | 32 | if resp.StatusCode != http.StatusOK { 33 | return 34 | } 35 | 36 | body, err := io.ReadAll(resp.Body) 37 | if err != nil { 38 | return 39 | } 40 | 41 | var response map[string]interface{} 42 | 43 | err = json.Unmarshal(body, &response) 44 | if err != nil { 45 | return 46 | } 47 | 48 | vCurrent, err := version.NewVersion(AppVersion) 49 | if err != nil { 50 | fmt.Print(err) 51 | } 52 | 53 | vLatest, err := version.NewVersion(response["tag_name"].(string)) 54 | if err != nil { 55 | fmt.Print(err) 56 | } 57 | 58 | // check if a newer version exists in the GitHub Releases 59 | if vCurrent.LessThan(vLatest) { 60 | color.Red(fmt.Sprintf("Please upgrade to the latest version of this tool (%s) by visiting %s\n\n", response["tag_name"], latestRelease)) 61 | } 62 | } 63 | 64 | func CheckAppVersion() { 65 | if AppVersion == "0.0.0" { 66 | version, err := os.ReadFile("VERSION") 67 | if err != nil { 68 | fmt.Println(err) 69 | } 70 | 71 | // manually assign the value from `./VERSION` if it wasn't assigned during compilation already. This makes sure 72 | // that also people that run/build the app manually (without compiling the `./VERSION` into the binary) get the 73 | // appropriate version 74 | AppVersion = string(version) 75 | } 76 | // if the AppVersion is not "0.0.0" at this point, it means it has been set when compiling the app, so we just leave that 77 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Go Version](https://img.shields.io/github/go-mod/go-version/dub-flow/sessionprobe) 2 | ![Docker Image Size](https://img.shields.io/docker/image-size/fw10/sessionprobe/latest) 3 | 4 | # SessionProbe 🚀⚡ 5 | 6 | `SessionProbe` is a multi-threaded pentesting tool designed to assist in evaluating user privileges in web applications. It takes a user's session token and checks for a list of URLs if access is possible, highlighting potential authorization issues. `SessionProbe` deduplicates URL lists and provides real-time logging and progress tracking. 7 | 8 | `SessionProbe` is intended to be used with `Burp Suite's` "Copy URLs in this host" functionality in the `Target` tab (available in the free `Community Edition`). 9 | 10 | **Note**: You may want to change the `filter` in `Burps's` `Target` tab to include files or images. Otherwise, these `URLs` would not be copied by "Copy URLs in this host" and would not be tested by `SessionProbe`. 11 | 12 | # Built-in Help 🆘 13 | 14 | Help is built-in! 15 | 16 | - `sessionprobe --help` - outputs the help. 17 | 18 | # How to Use ⚙ 19 | 20 | ```text 21 | Usage: 22 | sessionprobe [flags] 23 | 24 | Flags: 25 | -u, --urls string file containing the URLs to be checked (required) 26 | -H, --headers string HTTP headers to be used in the requests in the format "Key1:Value1;Key2:Value2;..." 27 | -h, --help help for sessionprobe 28 | --ignore-css ignore URLs ending with .css (default true) 29 | --ignore-js ignore URLs ending with .js (default true) 30 | -o, --out string output file (default "output.txt") 31 | -p, --proxy string proxy URL (default: "") 32 | -r, --filter-regex string exclude HTTP responses using a regex. Responses whose body matches this regex will not be part of the output. 33 | -l, --filter-lengths string exclude HTTP responses by body length. You can specify lengths separated by commas (e.g., "123,456,789"). 34 | --skip-verification skip verification of SSL certificates (default false) 35 | -t, --threads int number of threads (default 10) 36 | --check-all Check POST, DELETE, PUT & PATCH methods (default false) 37 | --check-delete Check DELETE method (default false) 38 | --check-patch Check PATCH method (default false) 39 | --check-post Check POST method (default false) 40 | --check-put Check PUT method (default false) 41 | 42 | Examples: 43 | ./sessionprobe -u ./urls.txt 44 | ./sessionprobe -u ./urls.txt --out ./unauthenticated-test.txt --threads 15 45 | ./sessionprobe -u ./urls.txt -H "Cookie: .AspNetCore.Cookies=" -o ./output.txt 46 | ./sessionprobe -u ./urls.txt -H "Authorization: Bearer " --proxy http://localhost:8080 47 | ./sessionprobe -u ./urls.txt -r "Page Not Found" 48 | ./sessionprobe -u ./urls.txt -H "Cookie: .AspNetCore.Cookies=;Cookie: =" 49 | ``` 50 | 51 | # Run via Docker 🐳 52 | 53 | 1. Navigate into the directory where your `URLs file` is. 54 | 2. Run the below command: 55 | ```text 56 | docker run -it --rm -v "$(pwd):/app/files" --name sessionprobe fw10/sessionprobe [flags] 57 | ``` 58 | - Note that we are mounting the current directory in. This means that your `URLs file` must be in the current directory and your `output file` will also be in this directory. 59 | - Also remember to have a `Burp listener` run on all interfaces if you want to use the `--proxy` option 60 | 61 | # Setup ✅ 62 | 63 | - You can simply run this tool from source via `go run .` 64 | - You can build the tool yourself via `go build` 65 | - You can build the docker image yourself via `docker build . -t fw10/sessionprobe` 66 | 67 | # Run Tests 🧪 68 | 69 | - To run the tests, run `go test` or `go test -v` (for more details) 70 | 71 | # Features 🔎 72 | 73 | - Test for authorization issues 74 | - Automatically dedupes URLs 75 | - Sorts the URLs by response status code and extension (e.g., `.css`, `.js`), and provides the length 76 | - Multi-threaded 77 | - Proxy functionality to pass all requests e.g. through `Burp` 78 | - ... 79 | 80 | # Example Output 📋 81 | 82 | ``` 83 | Responses with Status Code: 200 84 | 85 | https://example.com/ => Length: 12345 86 | https://example.com/ => Length: 40 87 | ... 88 | 89 | Responses with Status Code: 301 90 | 91 | https://example.com/ => Length: 890 92 | https://example.com/ => Length: 434 93 | ... 94 | 95 | Responses with Status Code: 302 96 | 97 | https://example.com/ => Length: 0 98 | ... 99 | 100 | Responses with Status Code: 404 101 | 102 | ... 103 | 104 | Responses with Status Code: 502 105 | 106 | ... 107 | 108 | ``` 109 | 110 | # Releases 🔑 111 | 112 | - The `Releases` section contains some already compiled binaries for you so that you might not have to build the tool yourself 113 | - For the `Mac releases`, your Mac may throw a warning (`"cannot be opened because it is from an unidentified developer"`) 114 | - To avoid this warning in the first place, you could simply build the app yourself (see `Setup`) 115 | - Alternatively, you may - at your own risk - bypass this warning following the guidance here: https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac 116 | - Afterwards, you can simply run the binary from the command line and provide the required flags 117 | 118 | # Bug Reports 🐞 119 | 120 | If you find a bug, please file an Issue right here in GitHub, and I will try to resolve it in a timely manner. 121 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/http/httptest" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "regexp" 11 | "strings" 12 | "testing" 13 | ) 14 | 15 | func TestParseHeaders(t *testing.T) { 16 | headersString := "Key1:Value1;Key2:Value2" 17 | expected := map[string][]string{ 18 | "Key1": {"Value1"}, 19 | "Key2": {"Value2"}, 20 | } 21 | 22 | result := parseHeaders(headersString) 23 | for k, vSlice := range expected { 24 | if resultSlice, ok := result[k]; !ok || len(resultSlice) != len(vSlice) { 25 | t.Errorf("Expected key %s to exist with %d value(s), but got %d", k, len(vSlice), len(resultSlice)) 26 | } else { 27 | for i, v := range vSlice { 28 | if resultSlice[i] != v { 29 | t.Errorf("Expected %s for key %s but got %s", v, k, resultSlice[i]) 30 | } 31 | } 32 | } 33 | } 34 | } 35 | 36 | func TestProcessResponse_MatchesRegex(t *testing.T) { 37 | compiledRegex, _ := regexp.Compile("World") 38 | statusCode := 200 39 | body := []byte("Hello, World!") 40 | expectedStatus, expectedLength, expectedMatched := 200, len(body), false 41 | excludedLengths := make(map[int]bool) 42 | 43 | actualStatus, actualLength, actualMatched := filterResponseByLengthAndRegex(statusCode, body, compiledRegex, excludedLengths) 44 | 45 | if actualStatus != expectedStatus || actualLength != expectedLength || actualMatched != expectedMatched { 46 | t.Errorf("Expected status %d, length %d, matched %v but got status %d, length %d, matched %v", 47 | expectedStatus, expectedLength, expectedMatched, actualStatus, actualLength, actualMatched) 48 | } 49 | } 50 | 51 | func TestProcessResponse_DoesNotMatchRegex(t *testing.T) { 52 | compiledRegex, _ := regexp.Compile("Bye") 53 | statusCode := 200 54 | body := []byte("Hello, World!") 55 | expectedStatus, expectedLength, expectedMatched := 200, len(body), true 56 | excludedLengths := make(map[int]bool) 57 | 58 | actualStatus, actualLength, actualMatched := filterResponseByLengthAndRegex(statusCode, body, compiledRegex, excludedLengths) 59 | 60 | if actualStatus != expectedStatus || actualLength != expectedLength || actualMatched != expectedMatched { 61 | t.Errorf("Expected status %d, length %d, matched %v but got status %d, length %d, matched %v", 62 | expectedStatus, expectedLength, expectedMatched, actualStatus, actualLength, actualMatched) 63 | } 64 | } 65 | 66 | func TestCheckURL_MatchesRegex(t *testing.T) { 67 | // Mock HTTP server 68 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 69 | w.WriteHeader(http.StatusOK) 70 | w.Write([]byte("Hello, World!")) 71 | })) 72 | defer server.Close() 73 | 74 | headers := make(map[string][]string) 75 | proxy := "" 76 | compiledRegex, _ := regexp.Compile("World") // Matching regex 77 | expectedStatus, expectedMatched := 200, false // It should filter out the response because it matches 78 | excludedLengths := make(map[int]bool) 79 | 80 | actualStatus, _, actualMatched := checkURL("GET", server.URL, headers, proxy, compiledRegex, excludedLengths) 81 | 82 | if actualStatus != expectedStatus || actualMatched != expectedMatched { 83 | t.Errorf("Expected status %d, matched %v but got status %d, matched %v", 84 | expectedStatus, expectedMatched, actualStatus, actualMatched) 85 | } 86 | } 87 | 88 | func TestCheckURL_DoesNotMatchRegex(t *testing.T) { 89 | // Mock HTTP server 90 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 91 | w.WriteHeader(http.StatusOK) 92 | w.Write([]byte("Hello, World!")) 93 | })) 94 | defer server.Close() 95 | 96 | headers := make(map[string][]string) 97 | proxy := "" 98 | compiledRegex, _ := regexp.Compile("Bye") // Non-matching regex 99 | expectedStatus, expectedMatched := 200, true // It should not filter out the response because it doesn't match 100 | excludedLengths := make(map[int]bool) 101 | 102 | actualStatus, _, actualMatched := checkURL("GET", server.URL, headers, proxy, compiledRegex, excludedLengths) 103 | 104 | if actualStatus != expectedStatus || actualMatched != expectedMatched { 105 | t.Errorf("Expected status %d, matched %v but got status %d, matched %v", 106 | expectedStatus, expectedMatched, actualStatus, actualMatched) 107 | } 108 | } 109 | 110 | func TestCheckURL_ExcludedLength(t *testing.T) { 111 | // Mock HTTP server with a fixed response length 112 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 113 | w.WriteHeader(http.StatusOK) 114 | w.Write([]byte("Hello, World!")) // Length is 13 115 | })) 116 | defer server.Close() 117 | 118 | headers := make(map[string][]string) 119 | proxy := "" 120 | compiledRegex, _ := regexp.Compile(".*") // Matching any string 121 | expectedStatus, expectedMatched := 200, false // It should filter out the response because of its length 122 | excludedLengths := map[int]bool{ 123 | 13: true, // Excluding the length 13 124 | } 125 | 126 | actualStatus, _, actualMatched := checkURL("GET", server.URL, headers, proxy, compiledRegex, excludedLengths) 127 | 128 | if actualStatus != expectedStatus || actualMatched != expectedMatched { 129 | t.Errorf("Expected status %d, matched %v but got status %d, matched %v", 130 | expectedStatus, expectedMatched, actualStatus, actualMatched) 131 | } 132 | } 133 | 134 | func TestFilterRegexFunctionality(t *testing.T) { 135 | // Ensure the 'testing' directory exists. 136 | EnsureOutputFolderExists(t) 137 | 138 | // 1. Set up a mock HTTP server. 139 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 140 | switch r.URL.Path { 141 | case "/shouldInclude": 142 | w.WriteHeader(http.StatusOK) 143 | fmt.Fprintln(w, "This should be included.") 144 | case "/shouldExclude": 145 | w.WriteHeader(http.StatusOK) 146 | fmt.Fprintln(w, "This should not be included.") 147 | default: 148 | w.WriteHeader(http.StatusInternalServerError) 149 | } 150 | })) 151 | defer ts.Close() 152 | 153 | // Use filepath.Join to construct the file path in an OS-agnostic manner. 154 | urlsFilePath := filepath.Join(".", "testing", "test-urls-regex.txt") 155 | // Open (or create if it doesn't exist) and truncate the URLs file. 156 | urlsFile, err := os.OpenFile(urlsFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 157 | if err != nil { 158 | t.Fatalf("Failed to open or create test file: %v", err) 159 | } 160 | defer urlsFile.Close() 161 | 162 | urls := []string{ 163 | ts.URL + "/shouldInclude", 164 | ts.URL + "/shouldExclude", 165 | } 166 | for _, url := range urls { 167 | if _, err := urlsFile.WriteString(url + "\n"); err != nil { 168 | t.Fatalf("Failed to write to test file: %v", err) 169 | } 170 | } 171 | 172 | // 2. Use go run main.go with filter-regex to probe the mock server. 173 | outputFile := filepath.Join(".", "testing", "test-output-regex.txt") 174 | cmd := exec.Command("go", "run", ".", "-u", urlsFilePath, "-o", outputFile, "--filter-regex", "This should not be included.") 175 | // cmd.Stdout = os.Stdout 176 | // cmd.Stderr = os.Stderr 177 | if err := cmd.Run(); err != nil { 178 | t.Fatalf("Failed to run main.go: %v", err) 179 | } 180 | 181 | // 3. Check the output. 182 | output, err := os.ReadFile(outputFile) 183 | if err != nil { 184 | t.Fatalf("Failed to read output file: %v", err) 185 | } 186 | outputStr := string(output) 187 | 188 | // Check if the URL of the "excluded" endpoint is present, even though its body should have caused it to be filtered out. 189 | if strings.Contains(outputStr, ts.URL+"/shouldExclude") { 190 | t.Fatalf("URL that should have been filtered by regex was found in the output: %v", outputStr) 191 | } 192 | 193 | // Check if the URL of the "included" endpoint is present. 194 | if !strings.Contains(outputStr, ts.URL+"/shouldInclude") { 195 | t.Fatalf("URL that shouldn't be filtered by regex was missing from the output: %v", outputStr) 196 | } 197 | } 198 | 199 | // ensures the ./testing folder exists for test files 200 | func EnsureOutputFolderExists(t *testing.T) { 201 | dir := filepath.Join(".", "testing") 202 | 203 | if _, err := os.Stat(dir); os.IsNotExist(err) { 204 | if err := os.Mkdir(dir, 0755); err != nil { 205 | t.Fatalf("Failed to create ./testing directory: %v", err) 206 | } 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net" 10 | "net/http" 11 | neturl "net/url" 12 | "os" 13 | "regexp" 14 | "sort" 15 | "strconv" 16 | "strings" 17 | "sync" 18 | "sync/atomic" 19 | "time" 20 | 21 | "github.com/fatih/color" 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | var ( 26 | headers string 27 | urls string 28 | threads int 29 | out string 30 | proxy string 31 | skipVerification bool 32 | filterRegex string 33 | filterLengths string 34 | ignoreCSS bool 35 | ignoreJS bool 36 | methodPOST bool 37 | methodPUT bool 38 | methodDELETE bool 39 | methodPATCH bool 40 | methodALL bool 41 | green = color.New(color.FgGreen).SprintFunc() 42 | red = color.New(color.FgRed).SprintFunc() 43 | yellow = color.New(color.FgYellow).SprintFunc() 44 | ) 45 | 46 | type Result struct { 47 | Method string 48 | URL string 49 | Length int 50 | } 51 | 52 | func main() { 53 | rootCmd := &cobra.Command{ 54 | Use: "sessionprobe", 55 | Short: "A tool for probing sessions", 56 | Long: `SessionProbe is a tool for probing multiple sessions and recording their responses.`, 57 | Example: `./sessionprobe -u ./urls.txt 58 | ./sessionprobe -u ./urls.txt --out ./unauthenticated-test.txt 59 | ./sessionprobe -u ./urls.txt --threads 15 -H "Cookie: .AspNetCore.Cookies=" -o ./output.txt 60 | ./sessionprobe -u ./urls.txt -H "Authorization: Bearer " --proxy http://localhost:8080 61 | ./sessionprobe -u ./urls.txt -r "Page Not Found" 62 | ./sessionprobe -u ./urls.txt -H "Cookie: .AspNetCore.Cookies=;Cookie: ="`, 63 | Run: run, 64 | } 65 | 66 | rootCmd.PersistentFlags().StringVarP(&headers, "headers", "H", "", "HTTP headers to be used in the requests in the format \"Key1:Value1;Key2:Value2;...\"") 67 | rootCmd.PersistentFlags().StringVarP(&urls, "urls", "u", "", "file containing the URLs to be checked (required)") 68 | rootCmd.PersistentFlags().IntVarP(&threads, "threads", "t", 10, "number of threads") 69 | rootCmd.PersistentFlags().StringVarP(&out, "out", "o", "output.txt", "output file") 70 | rootCmd.PersistentFlags().StringVarP(&proxy, "proxy", "p", "", "proxy URL (default: \"\")") 71 | rootCmd.PersistentFlags().BoolVar(&skipVerification, "skip-verification", false, "skip verification of SSL certificates (default false)") 72 | rootCmd.PersistentFlags().BoolVar(&ignoreCSS, "ignore-css", true, "ignore URLs ending with .css") 73 | rootCmd.PersistentFlags().BoolVar(&ignoreJS, "ignore-js", true, "ignore URLs ending with .js") 74 | rootCmd.PersistentFlags().StringVarP(&filterRegex, "filter-regex", "r", "", "Exclude HTTP responses using a regex. Responses whose body matches this regex will not be part of the output.") 75 | rootCmd.PersistentFlags().StringVarP(&filterLengths, "filter-lengths", "l", "", "Exclude HTTP responses by body length. You can specify lengths separated by commas (e.g., \"123,456,789\").") 76 | rootCmd.PersistentFlags().BoolVar(&methodPOST, "check-post", false, "Check POST method (default false)") 77 | rootCmd.PersistentFlags().BoolVar(&methodPUT, "check-put", false, "Check PUT method (default false)") 78 | rootCmd.PersistentFlags().BoolVar(&methodDELETE, "check-delete", false, "Check DELETE method (default false)") 79 | rootCmd.PersistentFlags().BoolVar(&methodPATCH, "check-patch", false, "Check PATCH method (default false)") 80 | rootCmd.PersistentFlags().BoolVar(&methodALL, "check-all", false, "Check POST, DELETE, PUT & PATCH methods (default false)") 81 | 82 | rootCmd.Execute() 83 | } 84 | 85 | // run() gets executed when the root command is called 86 | func run(cmd *cobra.Command, args []string) { 87 | printIntro() 88 | 89 | // check if the AppVersion was already set during compilation - otherwise manually get it from `./current_version` 90 | CheckAppVersion() 91 | color.Yellow("Current version: %s\n\n", AppVersion) 92 | 93 | // check if a later version of this tool exists 94 | NotifyOfUpdates() 95 | 96 | // the `urls` flag is required 97 | if urls == "" { 98 | Error("Please provide a URLs file using the '-urls ' argument.") 99 | Error("Use --help for more information.") 100 | return 101 | } 102 | 103 | if ignoreCSS { 104 | Info("Ignoring URLs that end with .css") 105 | } 106 | 107 | if ignoreJS { 108 | Info("Ignoring URLs that end with .js") 109 | } 110 | 111 | var headersMap map[string][]string 112 | if headers != "" { 113 | headersMap = parseHeaders(headers) 114 | } 115 | 116 | // if a proxy was provided, check if the proxy is reachable. Exit if it's not 117 | if proxy != "" { 118 | checkProxyReachability(proxy) 119 | } 120 | 121 | // compile the regex provided via `-fr` 122 | var compiledRegex *regexp.Regexp 123 | if filterRegex != "" { 124 | var err error 125 | compiledRegex, err = regexp.Compile(filterRegex) 126 | if err != nil { 127 | Error("Invalid regex: %s", err) 128 | return 129 | } 130 | } 131 | 132 | file, err := os.Open(urls) 133 | if err != nil { 134 | Error("%s", err) 135 | return 136 | } 137 | defer file.Close() 138 | 139 | // create semaphore with the specified number of threads 140 | sem := make(chan bool, threads) 141 | // make sure to wait for all threads to finish before exiting the program 142 | var wg sync.WaitGroup 143 | 144 | // using a map to deduplicate URLs 145 | urlsMap := readURLs(file) 146 | 147 | // map to store URLs by status code 148 | excludedLengths := parseLengths(filterLengths) 149 | urlStatuses := processURLs(urlsMap, headersMap, proxy, &wg, sem, compiledRegex, excludedLengths) 150 | 151 | // wait for all threads to finish 152 | wg.Wait() 153 | 154 | outFile, err := os.Create(out) 155 | if err != nil { 156 | Error("%s", err) 157 | return 158 | } 159 | defer outFile.Close() 160 | 161 | writeToFile(urlStatuses, outFile) 162 | } 163 | 164 | func printIntro() { 165 | color.Green("##################################\n") 166 | color.Green("# #\n") 167 | color.Green("# SessionProbe #\n") 168 | color.Green("# #\n") 169 | color.Green("# By dub-flow with ❤️ #\n") 170 | color.Green("# #\n") 171 | color.Green("##################################\n\n") 172 | } 173 | 174 | func readURLs(file *os.File) map[string]bool { 175 | // read the URLs line by line 176 | scanner := bufio.NewScanner(file) 177 | 178 | // deduplicate URLs 179 | urls := make(map[string]bool) 180 | for scanner.Scan() { 181 | url := scanner.Text() 182 | 183 | if (ignoreCSS && strings.HasSuffix(url, ".css")) || 184 | (ignoreJS && strings.HasSuffix(url, ".js")) { 185 | continue 186 | } 187 | 188 | urls[url] = true 189 | } 190 | 191 | if scanner.Err() != nil { 192 | Error("%s", scanner.Err()) 193 | } 194 | 195 | return urls 196 | } 197 | 198 | func getMethods() []string { 199 | out := []string{"GET"} 200 | Info("Running GET requests against every URL") 201 | 202 | if methodALL || methodPOST { 203 | out = append(out, "POST") 204 | Warn("Also running POST requests against every URL (this feature is currently in its initial development phase)") 205 | Warn("It currently sends a request to each URL with an empty body and observes the response") 206 | } 207 | 208 | if methodALL || methodPUT { 209 | out = append(out, "PUT") 210 | Warn("Also running PUT requests against every URL (this feature is currently in its initial development phase)") 211 | Warn("It currently sends a request to each URL with an empty body and observes the response") 212 | } 213 | 214 | if methodALL || methodPATCH { 215 | out = append(out, "PATCH") 216 | Warn("Also running PATCH requests against every URL (this feature is currently in its initial development phase)") 217 | Warn("It currently sends a request to each URL with an empty body and observes the response") 218 | } 219 | 220 | if methodALL || methodDELETE { 221 | out = append(out, "DELETE") 222 | Info("Also running DELETE requests against every URL") 223 | } 224 | 225 | return out 226 | } 227 | 228 | func processURLs(urls map[string]bool, headers map[string][]string, proxy string, wg *sync.WaitGroup, sem chan bool, compiledRegex *regexp.Regexp, allowedLengths map[int]bool) map[int][]Result { 229 | // map to store URLs by status code 230 | urlStatuses := make(map[int][]Result) 231 | var urlStatusesMutex sync.Mutex 232 | 233 | var methods []string = getMethods() 234 | 235 | // for the progress counter 236 | var processedCount int32 237 | totalUrls := int32(len(urls)) 238 | totalMethods := int32(len(methods)) 239 | totalRequests := totalUrls * totalMethods 240 | 241 | Info("Starting to check %d unique URLs (deduplicated) and %d methods => %d requests", totalUrls, totalMethods, totalRequests) 242 | Info("We use %d threads", threads) 243 | 244 | // process each URL in the deduplicated map 245 | for url := range urls { 246 | wg.Add(1) 247 | 248 | // will block if there is already `threads` threads running 249 | sem <- true 250 | 251 | // launch a new goroutine for each URL 252 | go func(url string) { 253 | // using defer to ensure the semaphore is released and the waitgroup is decremented regardless of where we exit in the function 254 | defer func() { 255 | // always release the semaphore token 256 | <-sem 257 | // always decrement the waitgroup counter 258 | wg.Done() 259 | }() 260 | 261 | // inside the goroutine of processURLs 262 | for _, method := range methods { 263 | statusCode, length, matched := checkURL(method, url, headers, proxy, compiledRegex, allowedLengths) 264 | if matched { 265 | urlStatusesMutex.Lock() 266 | urlStatuses[statusCode] = append(urlStatuses[statusCode], Result{Method: method, URL: url, Length: length}) 267 | urlStatusesMutex.Unlock() 268 | } 269 | 270 | // increment the processedCount and log progress 271 | atomic.AddInt32(&processedCount, 1) 272 | percentage := float64(processedCount) / float64(totalRequests) * 100 273 | Info("Progress: %.2f%% (%d/%d deduped URLs processed)", percentage, processedCount, totalRequests) 274 | } 275 | 276 | }(url) 277 | } 278 | 279 | return urlStatuses 280 | } 281 | 282 | // takes a map of HTTP status codes to URLs and writes it to the output file 283 | func writeToFile(urlStatuses map[int][]Result, outFile *os.File) { 284 | writer := bufio.NewWriter(outFile) 285 | 286 | // sort the map keys to ensure consistent output 287 | var keys []int 288 | for k := range urlStatuses { 289 | keys = append(keys, k) 290 | } 291 | sort.Ints(keys) 292 | 293 | for _, k := range keys { 294 | _, _ = writer.WriteString(fmt.Sprintf("Responses with Status Code: %d\n\n", k)) 295 | for _, result := range urlStatuses[k] { 296 | _, _ = writer.WriteString(fmt.Sprintf("| %s | %s => Length: %d\n", result.Method, result.URL, result.Length)) 297 | } 298 | _, _ = writer.WriteString("\n") 299 | } 300 | 301 | writer.Flush() 302 | } 303 | 304 | func parseLengths(lengths string) map[int]bool { 305 | lengthsMap := make(map[int]bool) 306 | 307 | // if the input string is empty (i.e. `-l` was not provided), return an empty map 308 | if lengths == "" { 309 | return lengthsMap 310 | } 311 | 312 | parts := strings.Split(lengths, ",") 313 | for _, part := range parts { 314 | length, err := strconv.Atoi(strings.TrimSpace(part)) 315 | if err != nil { 316 | Error("Invalid length: %s", part) 317 | continue 318 | } 319 | lengthsMap[length] = true 320 | } 321 | 322 | return lengthsMap 323 | } 324 | 325 | func parseHeaders(headers string) map[string][]string { 326 | headerMap := make(map[string][]string) 327 | pairs := strings.Split(headers, ";") 328 | 329 | for _, pair := range pairs { 330 | parts := strings.SplitN(pair, ":", 2) 331 | 332 | if len(parts) != 2 { 333 | Error("Invalid header format: %s", pair) 334 | continue 335 | } 336 | 337 | key := strings.TrimSpace(parts[0]) 338 | value := strings.TrimSpace(parts[1]) 339 | 340 | // accumulate headers of the same key, required for e.g. setting multiple cookies 341 | headerMap[key] = append(headerMap[key], value) 342 | } 343 | 344 | return headerMap 345 | } 346 | 347 | // function to do the HTTP request and check the response's status code and response length 348 | func checkURL(method string, url string, headers map[string][]string, proxy string, compiledRegex *regexp.Regexp, allowedLengths map[int]bool) (int, int, bool) { 349 | client := createHTTPClient(proxy) 350 | req, err := prepareHTTPRequest(method, url, headers) 351 | 352 | if err != nil { 353 | Error("Failed to create request: %s", err) 354 | return 0, 0, false 355 | } 356 | 357 | resp, err := client.Do(req) 358 | if handleHTTPError(err, url) { 359 | return 0, 0, false 360 | } 361 | defer resp.Body.Close() 362 | 363 | bodyBytes, err := readResponseBody(resp.Body, url) 364 | if err != nil { 365 | return resp.StatusCode, 0, false 366 | } 367 | 368 | // if a regex pattern is provided, check if the response matches 369 | return filterResponseByLengthAndRegex(resp.StatusCode, bodyBytes, compiledRegex, allowedLengths) 370 | } 371 | 372 | // setting up the HTTP client with potential proxy and other configurations 373 | func createHTTPClient(proxy string) *http.Client { 374 | proxyURLFunc := func(_ *http.Request) (*neturl.URL, error) { 375 | return neturl.Parse(proxy) 376 | } 377 | 378 | if proxy == "" { 379 | proxyURLFunc = http.ProxyFromEnvironment 380 | } 381 | 382 | // custom CheckRedirect function that always returns an error. This prevents the client from following any redirects 383 | noRedirect := func(req *http.Request, via []*http.Request) error { 384 | return http.ErrUseLastResponse 385 | } 386 | 387 | return &http.Client{ 388 | Transport: &http.Transport{ 389 | Proxy: proxyURLFunc, 390 | TLSClientConfig: &tls.Config{ 391 | // skip SSL verification if specified 392 | InsecureSkipVerify: skipVerification, 393 | }, 394 | }, 395 | Timeout: 10 * time.Second, // set timeout for HTTP requests 396 | CheckRedirect: noRedirect, // Set the custom redirect policy 397 | } 398 | } 399 | 400 | // create a new HTTP request and set the provided headers 401 | func prepareHTTPRequest(method string, url string, headers map[string][]string) (*http.Request, error) { 402 | req, err := http.NewRequest(method, url, nil) 403 | if err != nil { 404 | return nil, err 405 | } 406 | 407 | // Add custom headers to the request. If multiple cookies are provided, concatenate them. 408 | for key, values := range headers { 409 | if key == "Cookie" { 410 | // Join multiple cookie values into a single header 411 | req.Header.Set(key, strings.Join(values, "; ")) 412 | } else { 413 | // For other headers, just set the first value (modify this as needed) 414 | for _, value := range values { 415 | req.Header.Add(key, value) 416 | } 417 | } 418 | } 419 | 420 | return req, nil 421 | } 422 | 423 | func handleHTTPError(err error, url string) bool { 424 | if err != nil { 425 | if _, ok := err.(net.Error); ok { 426 | // log network errors separately 427 | Error("Network error for URL: %s - %s", url, err) 428 | 429 | // provide a hint for the 'x509: certificate signed by unknown authority' error 430 | if strings.Contains(err.Error(), "x509") { 431 | Error("You may be able to fix the x509 certificate error by providing the --skip-verification flag") 432 | } 433 | 434 | return true 435 | } 436 | // log other errors 437 | Error("Error fetching URL: %s - %s", url, err) 438 | return true 439 | } 440 | 441 | return false 442 | } 443 | 444 | func readResponseBody(body io.ReadCloser, url string) ([]byte, error) { 445 | bodyBytes, err := io.ReadAll(body) 446 | if err != nil { 447 | Error("Error reading response body for URL: %s - %s", url, err) 448 | return nil, err 449 | } 450 | 451 | return bodyBytes, nil 452 | } 453 | 454 | func filterResponseByLengthAndRegex(statusCode int, bodyBytes []byte, compiledRegex *regexp.Regexp, excludedLengths map[int]bool) (int, int, bool) { 455 | length := len(bodyBytes) 456 | 457 | // If the length is in the excludedLengths map, exclude the response. 458 | if excludedLengths[length] { 459 | return statusCode, length, false 460 | } 461 | 462 | // If there's no regex provided, don't filter out any responses. 463 | if compiledRegex == nil { 464 | return statusCode, length, true 465 | } 466 | 467 | // If a regex is provided, only return true if the response does NOT match the regex 468 | if !compiledRegex.Match(bodyBytes) { 469 | return statusCode, length, true 470 | } 471 | 472 | return statusCode, length, false 473 | } 474 | 475 | func checkProxyReachability(proxy string) { 476 | if proxy != "" { 477 | proxyURL, err := neturl.Parse(proxy) 478 | if err != nil { 479 | Error("Failed to parse proxy URL: %s", err) 480 | os.Exit(1) 481 | } 482 | 483 | _, err = net.DialTimeout("tcp", proxyURL.Host, 5*time.Second) 484 | if err != nil { 485 | Error("Failed to connect to the proxy: %s", err) 486 | Error("In case you're using docker to run the app, remember that you can't refer to the proxy as 'localhost' but need its IP :)") 487 | os.Exit(1) 488 | } 489 | } 490 | } 491 | 492 | func Info(format string, a ...interface{}) { 493 | log.Printf("%s", green(fmt.Sprintf(format, a...))) 494 | } 495 | 496 | func Warn(format string, a ...interface{}) { 497 | log.Printf("%s", yellow(fmt.Sprintf(format, a...))) 498 | } 499 | 500 | func Error(format string, a ...interface{}) { 501 | log.Printf("%s", red(fmt.Sprintf(format, a...))) 502 | } 503 | --------------------------------------------------------------------------------