diff --git a/pkg/config/config.go b/pkg/config/config.go index 2b76969..7815a22 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -42,6 +42,7 @@ type Settings struct { CloudStorage bool SubdomainTakeover bool Shodan bool + SQL bool } const ( @@ -85,6 +86,7 @@ func Parse() *Settings { flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"), flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"), flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"), + flagSet.BoolVar(&settings.SQL, "sql", false, "Enable SQL reconnaissance (admin panels, error disclosure)"), ) flagSet.CreateGroup("runtime", "Runtime", diff --git a/pkg/scan/sql.go b/pkg/scan/sql.go new file mode 100644 index 0000000..9646392 --- /dev/null +++ b/pkg/scan/sql.go @@ -0,0 +1,323 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "fmt" + "io" + "net/http" + "os" + "regexp" + "strings" + "sync" + "time" + + "github.com/charmbracelet/log" + "github.com/dropalldatabases/sif/internal/styles" + "github.com/dropalldatabases/sif/pkg/logger" +) + +// 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) { + fmt.Println(styles.Separator.Render("🗃️ Starting " + styles.Status.Render("SQL reconnaissance") + "...")) + + sanitizedURL := strings.Split(targetURL, "://")[1] + + if logdir != "" { + if err := logger.WriteHeader(sanitizedURL, logdir, "SQL reconnaissance"); err != nil { + log.Errorf("Error creating log file: %v", err) + return nil, err + } + } + + sqllog := log.NewWithOptions(os.Stderr, log.Options{ + Prefix: "SQL 🗃️", + }).With("url", targetURL) + + sqllog.Infof("Starting SQL reconnaissance...") + + result := &SQLResult{ + AdminPanels: []SQLAdminPanel{}, + DatabaseErrors: []SQLDatabaseError{}, + } + + var mu sync.Mutex + var wg sync.WaitGroup + + client := &http.Client{ + Timeout: timeout, + 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 + + resp, err := client.Get(checkURL) + if err != nil { + log.Debugf("Error checking %s: %v", checkURL, err) + continue + } + defer resp.Body.Close() + + // 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 + 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() + + sqllog.Warnf("Found %s at [%s] (status: %d)", + styles.SeverityHigh.Render(adminPath.panelType), + styles.Highlight.Render(checkURL), + resp.StatusCode) + + if logdir != "" { + logger.Write(sanitizedURL, logdir, fmt.Sprintf("Found %s at [%s] (status: %d)\n", adminPath.panelType, checkURL, resp.StatusCode)) + } + } + } + } + }() + } + wg.Wait() + + // check main URL for database errors + checkDatabaseErrors(client, targetURL, sanitizedURL, result, sqllog, logdir, &mu) + + // 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, sqllog, logdir, &mu) + } + + // summary + if len(result.AdminPanels) > 0 { + sqllog.Warnf("Found %d database admin panel(s)", len(result.AdminPanels)) + } + if len(result.DatabaseErrors) > 0 { + sqllog.Warnf("Found %d database error disclosure(s)", len(result.DatabaseErrors)) + } + + if len(result.AdminPanels) == 0 && len(result.DatabaseErrors) == 0 { + sqllog.Infof("No SQL exposures found") + return nil, nil + } + + 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, sqllog *log.Logger, logdir string, mu *sync.Mutex) { + resp, err := client.Get(checkURL) + 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) { + mu.Lock() + // check if we already have this error for this URL + found := false + for _, existing := range result.DatabaseErrors { + if existing.URL == checkURL && existing.DatabaseType == pattern.databaseType { + found = true + break + } + } + if !found { + dbError := SQLDatabaseError{ + URL: checkURL, + DatabaseType: pattern.databaseType, + ErrorPattern: pattern.pattern.String(), + } + result.DatabaseErrors = append(result.DatabaseErrors, dbError) + + sqllog.Warnf("Database error disclosure: %s at [%s]", + styles.SeverityHigh.Render(pattern.databaseType), + styles.Highlight.Render(checkURL)) + + if logdir != "" { + logger.Write(sanitizedURL, logdir, fmt.Sprintf("Database error disclosure: %s at [%s]\n", pattern.databaseType, checkURL)) + } + } + mu.Unlock() + break // only report one database type per URL + } + } +} diff --git a/pkg/scan/sql_test.go b/pkg/scan/sql_test.go new file mode 100644 index 0000000..c6a0611 --- /dev/null +++ b/pkg/scan/sql_test.go @@ -0,0 +1,280 @@ +/* +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +: : +: █▀ █ █▀▀ · Blazing-fast pentesting suite : +: ▄█ █ █▀ · BSD 3-Clause License : +: : +: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, : +: lunchcat alumni & contributors : +: : +·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━· +*/ + +package scan + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestIsAdminPanel_phpMyAdmin(t *testing.T) { + tests := []struct { + name string + body string + expected bool + }{ + {"contains phpMyAdmin", "phpMyAdmin", true}, + {"contains pma_", "", true}, + {"empty body", "", false}, + {"unrelated content", "Hello World", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isAdminPanel(tt.body, "phpMyAdmin") + if result != tt.expected { + t.Errorf("isAdminPanel(%q, 'phpMyAdmin') = %v, want %v", tt.body, result, tt.expected) + } + }) + } +} + +func TestIsAdminPanel_Adminer(t *testing.T) { + tests := []struct { + name string + body string + expected bool + }{ + {"contains Adminer", "Adminer", true}, + {"lowercase adminer", "
adminer version 4.8
", true}, + {"empty body", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isAdminPanel(tt.body, "Adminer") + if result != tt.expected { + t.Errorf("isAdminPanel(%q, 'Adminer') = %v, want %v", tt.body, result, tt.expected) + } + }) + } +} + +func TestIsAdminPanel_GenericDatabase(t *testing.T) { + tests := []struct { + name string + body string + expected bool + }{ + {"contains database", "Database Manager", true}, + {"contains sql", "
SQL Query Interface
", true}, + {"contains mysql", "", true}, + {"contains postgresql", "
PostgreSQL Admin
", true}, + {"empty body", "", false}, + {"unrelated content", "Blog", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isAdminPanel(tt.body, "Database Interface") + if result != tt.expected { + t.Errorf("isAdminPanel(%q, 'Database Interface') = %v, want %v", tt.body, result, tt.expected) + } + }) + } +} + +func TestSQLResult_Fields(t *testing.T) { + result := SQLResult{ + AdminPanels: []SQLAdminPanel{ + { + URL: "http://example.com/phpmyadmin/", + Type: "phpMyAdmin", + Status: 200, + }, + }, + DatabaseErrors: []SQLDatabaseError{ + { + URL: "http://example.com/?id=1'", + DatabaseType: "MySQL", + ErrorPattern: "mysql.*error", + }, + }, + } + + if len(result.AdminPanels) != 1 { + t.Errorf("expected 1 admin panel, got %d", len(result.AdminPanels)) + } + if result.AdminPanels[0].Type != "phpMyAdmin" { + t.Errorf("expected type 'phpMyAdmin', got '%s'", result.AdminPanels[0].Type) + } + if len(result.DatabaseErrors) != 1 { + t.Errorf("expected 1 database error, got %d", len(result.DatabaseErrors)) + } + if result.DatabaseErrors[0].DatabaseType != "MySQL" { + t.Errorf("expected database type 'MySQL', got '%s'", result.DatabaseErrors[0].DatabaseType) + } +} + +func TestDatabaseErrorPatterns_MySQL(t *testing.T) { + testCases := []struct { + name string + body string + expected bool + }{ + {"mysql error", "MySQL Error: Something went wrong", true}, + {"mysql syntax", "You have an error in your SQL syntax", true}, + {"mysql fetch", "Warning: mysql_fetch_array()", true}, + {"no error", "Welcome to our website", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + found := false + for _, pattern := range databaseErrorPatterns { + if pattern.pattern.MatchString(tc.body) { + found = true + break + } + } + if found != tc.expected { + t.Errorf("pattern match for %q = %v, want %v", tc.body, found, tc.expected) + } + }) + } +} + +func TestDatabaseErrorPatterns_PostgreSQL(t *testing.T) { + testCases := []struct { + name string + body string + expected bool + }{ + {"postgresql error", "PostgreSQL Error: connection failed", true}, + {"pg_query", "Warning: pg_query(): Query failed", true}, + {"unterminated string", "ERROR: unterminated quoted string", true}, + {"no error", "Welcome to our website", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + found := false + for _, pattern := range databaseErrorPatterns { + if pattern.pattern.MatchString(tc.body) { + found = true + break + } + } + if found != tc.expected { + t.Errorf("pattern match for %q = %v, want %v", tc.body, found, tc.expected) + } + }) + } +} + +func TestDatabaseErrorPatterns_SQLServer(t *testing.T) { + testCases := []struct { + name string + body string + expected bool + }{ + {"mssql error", "MSSQL Error: invalid query", true}, + {"sql server error", "Microsoft SQL Server Error", true}, + {"unclosed quote", "Unclosed quotation mark after the character string", true}, + {"no error", "Welcome to our website", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + found := false + for _, pattern := range databaseErrorPatterns { + if pattern.pattern.MatchString(tc.body) { + found = true + break + } + } + if found != tc.expected { + t.Errorf("pattern match for %q = %v, want %v", tc.body, found, tc.expected) + } + }) + } +} + +func TestDatabaseErrorPatterns_Oracle(t *testing.T) { + testCases := []struct { + name string + body string + expected bool + }{ + {"ora error code", "ORA-00942: table or view does not exist", true}, + {"oracle error", "Oracle Error: invalid identifier", true}, + {"no error", "Welcome to our website", false}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + found := false + for _, pattern := range databaseErrorPatterns { + if pattern.pattern.MatchString(tc.body) { + found = true + break + } + } + if found != tc.expected { + t.Errorf("pattern match for %q = %v, want %v", tc.body, found, tc.expected) + } + }) + } +} + +func TestSQLAdminPanelDetection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/phpmyadmin/": + w.WriteHeader(http.StatusOK) + w.Write([]byte("phpMyAdmin")) + case "/adminer/": + w.WriteHeader(http.StatusOK) + w.Write([]byte("Adminer")) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + // this is a basic test to verify the server mock works + resp, err := http.Get(server.URL + "/phpmyadmin/") + if err != nil { + t.Fatalf("failed to get phpmyadmin: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200 for /phpmyadmin/, got %d", resp.StatusCode) + } +} + +func TestSQLDatabaseErrorDetection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("id") == "1'" { + w.WriteHeader(http.StatusOK) + w.Write([]byte("MySQL Error: You have an error in your SQL syntax")) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte("Welcome to our website")) + } + })) + defer server.Close() + + // verify server returns mysql error for injection attempt + resp, err := http.Get(server.URL + "/?id=1'") + if err != nil { + t.Fatalf("failed to make request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status 200, got %d", resp.StatusCode) + } +} diff --git a/sif.go b/sif.go index 8a74e3b..b0f7dd3 100644 --- a/sif.go +++ b/sif.go @@ -257,6 +257,16 @@ func (app *App) Run() error { } } + if app.settings.SQL { + result, err := scan.SQL(url, app.settings.Timeout, app.settings.Threads, app.settings.LogDir) + if err != nil { + log.Errorf("Error while running SQL reconnaissance: %s", err) + } else if result != nil { + moduleResults = append(moduleResults, ModuleResult{"sql", result}) + scansRun = append(scansRun, "SQL Recon") + } + } + if app.settings.ApiMode { result := UrlResult{ Url: url,