Files
sif/internal/scan/sql.go
T
vmfunc d0bdcf1690 feat: shared http client with proxy, custom headers and rate limiting
every scanner spun up its own &http.Client, so there was no single place
to apply a proxy, custom headers, a cookie or a rate limit. add an
internal/httpx package that builds one configured transport at startup and
hand it to every scanner via httpx.Client(timeout), keeping behavior
identical when nothing is set (plain client when Configure was never
called).

- httpx.Configure wires -proxy (http/https/socks5), -H/--header, -cookie
  and -rate-limit into a package-level RoundTripper that paces via a
  rate.Limiter and only sets headers the caller hasn't already, so a
  scanner's explicit api key still wins.
- route the scan/wordlist downloads that used http.DefaultClient through
  the shared client too; ports tcp dialing is left untouched.
- clamp -threads to a floor of 1: it feeds wg.Add across the scanners, so
  0 was a silent no-op and a negative value panicked the waitgroup.

document the new flags in the readme, usage docs and man page.
2026-06-09 17:28:14 -07:00

338 lines
11 KiB
Go

/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"context"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
charmlog "github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
)
// SQLResult represents the results of SQL reconnaissance
type SQLResult struct {
AdminPanels []SQLAdminPanel `json:"admin_panels,omitempty"`
DatabaseErrors []SQLDatabaseError `json:"database_errors,omitempty"`
ExposedPorts []int `json:"exposed_ports,omitempty"`
}
// SQLAdminPanel represents a found database admin panel
type SQLAdminPanel struct {
URL string `json:"url"`
Type string `json:"type"`
Status int `json:"status"`
}
// SQLDatabaseError represents a detected database error
type SQLDatabaseError struct {
URL string `json:"url"`
DatabaseType string `json:"database_type"`
ErrorPattern string `json:"error_pattern"`
}
// common database admin panel paths
var sqlAdminPaths = []struct {
path string
panelType string
}{
{"/phpmyadmin/", "phpMyAdmin"},
{"/phpMyAdmin/", "phpMyAdmin"},
{"/pma/", "phpMyAdmin"},
{"/PMA/", "phpMyAdmin"},
{"/mysql/", "phpMyAdmin"},
{"/myadmin/", "phpMyAdmin"},
{"/MyAdmin/", "phpMyAdmin"},
{"/adminer/", "Adminer"},
{"/adminer.php", "Adminer"},
{"/pgadmin/", "pgAdmin"},
{"/phppgadmin/", "phpPgAdmin"},
{"/sql/", "SQL Interface"},
{"/db/", "Database Interface"},
{"/database/", "Database Interface"},
{"/dbadmin/", "Database Admin"},
{"/mysql-admin/", "MySQL Admin"},
{"/mysqladmin/", "MySQL Admin"},
{"/sqlmanager/", "SQL Manager"},
{"/websql/", "WebSQL"},
{"/sqlweb/", "SQLWeb"},
{"/rockmongo/", "RockMongo"},
{"/mongodb/", "MongoDB Interface"},
{"/mongo/", "MongoDB Interface"},
{"/redis/", "Redis Interface"},
{"/redis-commander/", "Redis Commander"},
{"/phpredisadmin/", "phpRedisAdmin"},
}
// database error patterns to detect database type
var databaseErrorPatterns = []struct {
pattern *regexp.Regexp
databaseType string
}{
{regexp.MustCompile(`(?i)mysql.*error`), "MySQL"},
{regexp.MustCompile(`(?i)mysql.*syntax`), "MySQL"},
{regexp.MustCompile(`(?i)you have an error in your sql syntax`), "MySQL"},
{regexp.MustCompile(`(?i)warning.*mysql`), "MySQL"},
{regexp.MustCompile(`(?i)mysql_fetch`), "MySQL"},
{regexp.MustCompile(`(?i)mysql_num_rows`), "MySQL"},
{regexp.MustCompile(`(?i)mysqli`), "MySQL"},
{regexp.MustCompile(`(?i)postgresql.*error`), "PostgreSQL"},
{regexp.MustCompile(`(?i)pg_query`), "PostgreSQL"},
{regexp.MustCompile(`(?i)pg_exec`), "PostgreSQL"},
{regexp.MustCompile(`(?i)psql.*error`), "PostgreSQL"},
{regexp.MustCompile(`(?i)unterminated quoted string`), "PostgreSQL"},
{regexp.MustCompile(`(?i)microsoft.*odbc.*sql server`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)mssql.*error`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)sql server.*error`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)unclosed quotation mark`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)sqlsrv`), "Microsoft SQL Server"},
{regexp.MustCompile(`(?i)ora-\d{5}`), "Oracle"},
{regexp.MustCompile(`(?i)oracle.*error`), "Oracle"},
{regexp.MustCompile(`(?i)oci_`), "Oracle"},
{regexp.MustCompile(`(?i)sqlite.*error`), "SQLite"},
{regexp.MustCompile(`(?i)sqlite3`), "SQLite"},
{regexp.MustCompile(`(?i)sqlite_`), "SQLite"},
{regexp.MustCompile(`(?i)mongodb.*error`), "MongoDB"},
{regexp.MustCompile(`(?i)document.*bson`), "MongoDB"},
}
// SQL performs SQL reconnaissance on the target URL
func SQL(targetURL string, timeout time.Duration, threads int, logdir string) (*SQLResult, error) {
log := output.Module("SQL")
log.Start()
spin := output.NewSpinner("Scanning for SQL exposures")
spin.Start()
sanitizedURL := stripScheme(targetURL)
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "SQL reconnaissance"); err != nil {
spin.Stop()
log.Error("Error creating log file: %v", err)
return nil, err
}
}
result := &SQLResult{
AdminPanels: make([]SQLAdminPanel, 0, 8),
DatabaseErrors: make([]SQLDatabaseError, 0, 8),
}
seenErrors := make(map[string]bool)
var mu sync.Mutex
var wg sync.WaitGroup
client := httpx.Client(timeout)
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return http.ErrUseLastResponse
}
return nil
}
// check for admin panels
wg.Add(threads)
adminPathsChan := make(chan int, len(sqlAdminPaths))
for i := range sqlAdminPaths {
adminPathsChan <- i
}
close(adminPathsChan)
for t := 0; t < threads; t++ {
go func() {
defer wg.Done()
for idx := range adminPathsChan {
adminPath := sqlAdminPaths[idx]
checkURL := strings.TrimSuffix(targetURL, "/") + adminPath.path
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, checkURL, http.NoBody)
if err != nil {
charmlog.Debugf("Error creating request for %s: %v", checkURL, err)
continue
}
resp, err := client.Do(req)
if err != nil {
charmlog.Debugf("Error checking %s: %v", checkURL, err)
continue
}
// check for successful response (not 404)
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
// read body to check for common admin panel indicators
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // limit to 100KB
resp.Body.Close()
if err != nil {
continue
}
bodyStr := string(body)
// check if it's actually an admin panel (not just a generic page)
if isAdminPanel(bodyStr, adminPath.panelType) {
mu.Lock()
panel := SQLAdminPanel{
URL: checkURL,
Type: adminPath.panelType,
Status: resp.StatusCode,
}
result.AdminPanels = append(result.AdminPanels, panel)
mu.Unlock()
spin.Stop()
log.Warn("Found %s at %s (status: %d)",
output.SeverityHigh.Render(adminPath.panelType),
output.Highlight.Render(checkURL),
resp.StatusCode)
spin.Start()
if logdir != "" {
logger.Write(sanitizedURL, logdir, "Found "+adminPath.panelType+" at ["+checkURL+"] (status: "+strconv.Itoa(resp.StatusCode)+")\n")
}
}
} else {
resp.Body.Close()
}
}
}()
}
wg.Wait()
// check main URL for database errors
checkDatabaseErrors(client, targetURL, sanitizedURL, result, logdir, &mu, seenErrors)
// check common endpoints that might expose database errors
errorCheckPaths := []string{
"/?id=1'",
"/?id=1\"",
"/?page=1'",
"/?q=test'",
"/search?q=test'",
"/login",
"/api/",
}
for _, path := range errorCheckPaths {
checkURL := strings.TrimSuffix(targetURL, "/") + path
checkDatabaseErrors(client, checkURL, sanitizedURL, result, logdir, &mu, seenErrors)
}
spin.Stop()
// summary
totalFindings := len(result.AdminPanels) + len(result.DatabaseErrors)
if len(result.AdminPanels) > 0 {
log.Warn("Found %d database admin panel(s)", len(result.AdminPanels))
}
if len(result.DatabaseErrors) > 0 {
log.Warn("Found %d database error disclosure(s)", len(result.DatabaseErrors))
}
if totalFindings == 0 {
log.Info("No SQL exposures found")
log.Complete(0, "found")
return nil, nil //nolint:nilnil // no SQLi found is not an error
}
log.Complete(totalFindings, "found")
return result, nil
}
func isAdminPanel(body string, panelType string) bool {
bodyLower := strings.ToLower(body)
switch panelType {
case "phpMyAdmin":
return strings.Contains(bodyLower, "phpmyadmin") ||
strings.Contains(bodyLower, "pma_") ||
strings.Contains(body, "phpMyAdmin")
case "Adminer":
return strings.Contains(bodyLower, "adminer") ||
strings.Contains(body, "Adminer")
case "pgAdmin":
return strings.Contains(bodyLower, "pgadmin") ||
strings.Contains(body, "pgAdmin")
case "phpPgAdmin":
return strings.Contains(bodyLower, "phppgadmin")
case "RockMongo":
return strings.Contains(bodyLower, "rockmongo")
case "Redis Commander":
return strings.Contains(bodyLower, "redis commander") ||
strings.Contains(bodyLower, "redis-commander")
case "phpRedisAdmin":
return strings.Contains(bodyLower, "phpredisadmin")
default:
// for generic database interfaces, check for common keywords
return strings.Contains(bodyLower, "database") ||
strings.Contains(bodyLower, "sql") ||
strings.Contains(bodyLower, "query") ||
strings.Contains(bodyLower, "mysql") ||
strings.Contains(bodyLower, "postgresql") ||
strings.Contains(bodyLower, "mongodb")
}
}
func checkDatabaseErrors(client *http.Client, checkURL, sanitizedURL string, result *SQLResult, logdir string, mu *sync.Mutex, seen map[string]bool) {
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, checkURL, http.NoBody)
if err != nil {
return
}
resp, err := client.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100))
if err != nil {
return
}
bodyStr := string(body)
for _, pattern := range databaseErrorPatterns {
if pattern.pattern.MatchString(bodyStr) {
key := checkURL + "|" + pattern.databaseType
mu.Lock()
if seen[key] {
mu.Unlock()
break
}
seen[key] = true
dbError := SQLDatabaseError{
URL: checkURL,
DatabaseType: pattern.databaseType,
ErrorPattern: pattern.pattern.String(),
}
result.DatabaseErrors = append(result.DatabaseErrors, dbError)
mu.Unlock()
output.Warn("Database error disclosure: %s at %s",
output.SeverityHigh.Render(pattern.databaseType),
output.Highlight.Render(checkURL))
if logdir != "" {
logger.Write(sanitizedURL, logdir, "Database error disclosure: "+pattern.databaseType+" at ["+checkURL+"]\n")
}
break // only report one database type per URL
}
}
}