feat: add sql reconnaissance module (#48)

adds a new --sql flag that performs sql reconnaissance on target urls:
- detects common database admin panels (phpmyadmin, adminer, pgadmin, etc.)
- identifies database error disclosure (mysql, postgresql, mssql, oracle, sqlite)
- scans common paths for sql injection indicators

closes #3
This commit is contained in:
Celeste Hickenlooper
2026-01-02 18:40:06 -08:00
committed by GitHub
parent 44842dd659
commit 3ba18a956a
4 changed files with 615 additions and 0 deletions

View File

@@ -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",

323
pkg/scan/sql.go Normal file
View File

@@ -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
}
}
}

280
pkg/scan/sql_test.go Normal file
View File

@@ -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", "<html><title>phpMyAdmin</title></html>", true},
{"contains pma_", "<script>var pma_token = '123';</script>", true},
{"empty body", "", false},
{"unrelated content", "<html><title>Hello World</title></html>", 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", "<html><title>Adminer</title></html>", true},
{"lowercase adminer", "<div>adminer version 4.8</div>", 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", "<html><title>Database Manager</title></html>", true},
{"contains sql", "<div>SQL Query Interface</div>", true},
{"contains mysql", "<script>mysql_query()</script>", true},
{"contains postgresql", "<div>PostgreSQL Admin</div>", true},
{"empty body", "", false},
{"unrelated content", "<html><title>Blog</title></html>", 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("<html><title>phpMyAdmin</title></html>"))
case "/adminer/":
w.WriteHeader(http.StatusOK)
w.Write([]byte("<html><title>Adminer</title></html>"))
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)
}
}

10
sif.go
View File

@@ -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,