mirror of
https://github.com/lunchcat/sif.git
synced 2026-06-12 19:11:25 -07:00
d0bdcf1690
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.
338 lines
11 KiB
Go
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
|
|
}
|
|
}
|
|
}
|