Skip to content

Commit 3ba18a9

Browse files
authored
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
1 parent 44842dd commit 3ba18a9

4 files changed

Lines changed: 615 additions & 0 deletions

File tree

pkg/config/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Settings struct {
4242
CloudStorage bool
4343
SubdomainTakeover bool
4444
Shodan bool
45+
SQL bool
4546
}
4647

4748
const (
@@ -85,6 +86,7 @@ func Parse() *Settings {
8586
flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"),
8687
flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"),
8788
flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"),
89+
flagSet.BoolVar(&settings.SQL, "sql", false, "Enable SQL reconnaissance (admin panels, error disclosure)"),
8890
)
8991

9092
flagSet.CreateGroup("runtime", "Runtime",

pkg/scan/sql.go

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
/*
2+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
3+
: :
4+
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
5+
: ▄█ █ █▀ · BSD 3-Clause License :
6+
: :
7+
: (c) 2022-2025 vmfunc (Celeste Hickenlooper), xyzeva, :
8+
: lunchcat alumni & contributors :
9+
: :
10+
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
11+
*/
12+
13+
package scan
14+
15+
import (
16+
"fmt"
17+
"io"
18+
"net/http"
19+
"os"
20+
"regexp"
21+
"strings"
22+
"sync"
23+
"time"
24+
25+
"github.com/charmbracelet/log"
26+
"github.com/dropalldatabases/sif/internal/styles"
27+
"github.com/dropalldatabases/sif/pkg/logger"
28+
)
29+
30+
// SQLResult represents the results of SQL reconnaissance
31+
type SQLResult struct {
32+
AdminPanels []SQLAdminPanel `json:"admin_panels,omitempty"`
33+
DatabaseErrors []SQLDatabaseError `json:"database_errors,omitempty"`
34+
ExposedPorts []int `json:"exposed_ports,omitempty"`
35+
}
36+
37+
// SQLAdminPanel represents a found database admin panel
38+
type SQLAdminPanel struct {
39+
URL string `json:"url"`
40+
Type string `json:"type"`
41+
Status int `json:"status"`
42+
}
43+
44+
// SQLDatabaseError represents a detected database error
45+
type SQLDatabaseError struct {
46+
URL string `json:"url"`
47+
DatabaseType string `json:"database_type"`
48+
ErrorPattern string `json:"error_pattern"`
49+
}
50+
51+
// common database admin panel paths
52+
var sqlAdminPaths = []struct {
53+
path string
54+
panelType string
55+
}{
56+
{"/phpmyadmin/", "phpMyAdmin"},
57+
{"/phpMyAdmin/", "phpMyAdmin"},
58+
{"/pma/", "phpMyAdmin"},
59+
{"/PMA/", "phpMyAdmin"},
60+
{"/mysql/", "phpMyAdmin"},
61+
{"/myadmin/", "phpMyAdmin"},
62+
{"/MyAdmin/", "phpMyAdmin"},
63+
{"/adminer/", "Adminer"},
64+
{"/adminer.php", "Adminer"},
65+
{"/pgadmin/", "pgAdmin"},
66+
{"/phppgadmin/", "phpPgAdmin"},
67+
{"/sql/", "SQL Interface"},
68+
{"/db/", "Database Interface"},
69+
{"/database/", "Database Interface"},
70+
{"/dbadmin/", "Database Admin"},
71+
{"/mysql-admin/", "MySQL Admin"},
72+
{"/mysqladmin/", "MySQL Admin"},
73+
{"/sqlmanager/", "SQL Manager"},
74+
{"/websql/", "WebSQL"},
75+
{"/sqlweb/", "SQLWeb"},
76+
{"/rockmongo/", "RockMongo"},
77+
{"/mongodb/", "MongoDB Interface"},
78+
{"/mongo/", "MongoDB Interface"},
79+
{"/redis/", "Redis Interface"},
80+
{"/redis-commander/", "Redis Commander"},
81+
{"/phpredisadmin/", "phpRedisAdmin"},
82+
}
83+
84+
// database error patterns to detect database type
85+
var databaseErrorPatterns = []struct {
86+
pattern *regexp.Regexp
87+
databaseType string
88+
}{
89+
{regexp.MustCompile(`(?i)mysql.*error`), "MySQL"},
90+
{regexp.MustCompile(`(?i)mysql.*syntax`), "MySQL"},
91+
{regexp.MustCompile(`(?i)you have an error in your sql syntax`), "MySQL"},
92+
{regexp.MustCompile(`(?i)warning.*mysql`), "MySQL"},
93+
{regexp.MustCompile(`(?i)mysql_fetch`), "MySQL"},
94+
{regexp.MustCompile(`(?i)mysql_num_rows`), "MySQL"},
95+
{regexp.MustCompile(`(?i)mysqli`), "MySQL"},
96+
{regexp.MustCompile(`(?i)postgresql.*error`), "PostgreSQL"},
97+
{regexp.MustCompile(`(?i)pg_query`), "PostgreSQL"},
98+
{regexp.MustCompile(`(?i)pg_exec`), "PostgreSQL"},
99+
{regexp.MustCompile(`(?i)psql.*error`), "PostgreSQL"},
100+
{regexp.MustCompile(`(?i)unterminated quoted string`), "PostgreSQL"},
101+
{regexp.MustCompile(`(?i)microsoft.*odbc.*sql server`), "Microsoft SQL Server"},
102+
{regexp.MustCompile(`(?i)mssql.*error`), "Microsoft SQL Server"},
103+
{regexp.MustCompile(`(?i)sql server.*error`), "Microsoft SQL Server"},
104+
{regexp.MustCompile(`(?i)unclosed quotation mark`), "Microsoft SQL Server"},
105+
{regexp.MustCompile(`(?i)sqlsrv`), "Microsoft SQL Server"},
106+
{regexp.MustCompile(`(?i)ora-\d{5}`), "Oracle"},
107+
{regexp.MustCompile(`(?i)oracle.*error`), "Oracle"},
108+
{regexp.MustCompile(`(?i)oci_`), "Oracle"},
109+
{regexp.MustCompile(`(?i)sqlite.*error`), "SQLite"},
110+
{regexp.MustCompile(`(?i)sqlite3`), "SQLite"},
111+
{regexp.MustCompile(`(?i)sqlite_`), "SQLite"},
112+
{regexp.MustCompile(`(?i)mongodb.*error`), "MongoDB"},
113+
{regexp.MustCompile(`(?i)document.*bson`), "MongoDB"},
114+
}
115+
116+
// SQL performs SQL reconnaissance on the target URL
117+
func SQL(targetURL string, timeout time.Duration, threads int, logdir string) (*SQLResult, error) {
118+
fmt.Println(styles.Separator.Render("🗃️ Starting " + styles.Status.Render("SQL reconnaissance") + "..."))
119+
120+
sanitizedURL := strings.Split(targetURL, "://")[1]
121+
122+
if logdir != "" {
123+
if err := logger.WriteHeader(sanitizedURL, logdir, "SQL reconnaissance"); err != nil {
124+
log.Errorf("Error creating log file: %v", err)
125+
return nil, err
126+
}
127+
}
128+
129+
sqllog := log.NewWithOptions(os.Stderr, log.Options{
130+
Prefix: "SQL 🗃️",
131+
}).With("url", targetURL)
132+
133+
sqllog.Infof("Starting SQL reconnaissance...")
134+
135+
result := &SQLResult{
136+
AdminPanels: []SQLAdminPanel{},
137+
DatabaseErrors: []SQLDatabaseError{},
138+
}
139+
140+
var mu sync.Mutex
141+
var wg sync.WaitGroup
142+
143+
client := &http.Client{
144+
Timeout: timeout,
145+
CheckRedirect: func(req *http.Request, via []*http.Request) error {
146+
if len(via) >= 3 {
147+
return http.ErrUseLastResponse
148+
}
149+
return nil
150+
},
151+
}
152+
153+
// check for admin panels
154+
wg.Add(threads)
155+
adminPathsChan := make(chan int, len(sqlAdminPaths))
156+
for i := range sqlAdminPaths {
157+
adminPathsChan <- i
158+
}
159+
close(adminPathsChan)
160+
161+
for t := 0; t < threads; t++ {
162+
go func() {
163+
defer wg.Done()
164+
for idx := range adminPathsChan {
165+
adminPath := sqlAdminPaths[idx]
166+
checkURL := strings.TrimSuffix(targetURL, "/") + adminPath.path
167+
168+
resp, err := client.Get(checkURL)
169+
if err != nil {
170+
log.Debugf("Error checking %s: %v", checkURL, err)
171+
continue
172+
}
173+
defer resp.Body.Close()
174+
175+
// check for successful response (not 404)
176+
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized {
177+
// read body to check for common admin panel indicators
178+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100)) // limit to 100KB
179+
if err != nil {
180+
continue
181+
}
182+
bodyStr := string(body)
183+
184+
// check if it's actually an admin panel (not just a generic page)
185+
if isAdminPanel(bodyStr, adminPath.panelType) {
186+
mu.Lock()
187+
panel := SQLAdminPanel{
188+
URL: checkURL,
189+
Type: adminPath.panelType,
190+
Status: resp.StatusCode,
191+
}
192+
result.AdminPanels = append(result.AdminPanels, panel)
193+
mu.Unlock()
194+
195+
sqllog.Warnf("Found %s at [%s] (status: %d)",
196+
styles.SeverityHigh.Render(adminPath.panelType),
197+
styles.Highlight.Render(checkURL),
198+
resp.StatusCode)
199+
200+
if logdir != "" {
201+
logger.Write(sanitizedURL, logdir, fmt.Sprintf("Found %s at [%s] (status: %d)\n", adminPath.panelType, checkURL, resp.StatusCode))
202+
}
203+
}
204+
}
205+
}
206+
}()
207+
}
208+
wg.Wait()
209+
210+
// check main URL for database errors
211+
checkDatabaseErrors(client, targetURL, sanitizedURL, result, sqllog, logdir, &mu)
212+
213+
// check common endpoints that might expose database errors
214+
errorCheckPaths := []string{
215+
"/?id=1'",
216+
"/?id=1\"",
217+
"/?page=1'",
218+
"/?q=test'",
219+
"/search?q=test'",
220+
"/login",
221+
"/api/",
222+
}
223+
224+
for _, path := range errorCheckPaths {
225+
checkURL := strings.TrimSuffix(targetURL, "/") + path
226+
checkDatabaseErrors(client, checkURL, sanitizedURL, result, sqllog, logdir, &mu)
227+
}
228+
229+
// summary
230+
if len(result.AdminPanels) > 0 {
231+
sqllog.Warnf("Found %d database admin panel(s)", len(result.AdminPanels))
232+
}
233+
if len(result.DatabaseErrors) > 0 {
234+
sqllog.Warnf("Found %d database error disclosure(s)", len(result.DatabaseErrors))
235+
}
236+
237+
if len(result.AdminPanels) == 0 && len(result.DatabaseErrors) == 0 {
238+
sqllog.Infof("No SQL exposures found")
239+
return nil, nil
240+
}
241+
242+
return result, nil
243+
}
244+
245+
func isAdminPanel(body string, panelType string) bool {
246+
bodyLower := strings.ToLower(body)
247+
248+
switch panelType {
249+
case "phpMyAdmin":
250+
return strings.Contains(bodyLower, "phpmyadmin") ||
251+
strings.Contains(bodyLower, "pma_") ||
252+
strings.Contains(body, "phpMyAdmin")
253+
case "Adminer":
254+
return strings.Contains(bodyLower, "adminer") ||
255+
strings.Contains(body, "Adminer")
256+
case "pgAdmin":
257+
return strings.Contains(bodyLower, "pgadmin") ||
258+
strings.Contains(body, "pgAdmin")
259+
case "phpPgAdmin":
260+
return strings.Contains(bodyLower, "phppgadmin")
261+
case "RockMongo":
262+
return strings.Contains(bodyLower, "rockmongo")
263+
case "Redis Commander":
264+
return strings.Contains(bodyLower, "redis commander") ||
265+
strings.Contains(bodyLower, "redis-commander")
266+
case "phpRedisAdmin":
267+
return strings.Contains(bodyLower, "phpredisadmin")
268+
default:
269+
// for generic database interfaces, check for common keywords
270+
return strings.Contains(bodyLower, "database") ||
271+
strings.Contains(bodyLower, "sql") ||
272+
strings.Contains(bodyLower, "query") ||
273+
strings.Contains(bodyLower, "mysql") ||
274+
strings.Contains(bodyLower, "postgresql") ||
275+
strings.Contains(bodyLower, "mongodb")
276+
}
277+
}
278+
279+
func checkDatabaseErrors(client *http.Client, checkURL, sanitizedURL string, result *SQLResult, sqllog *log.Logger, logdir string, mu *sync.Mutex) {
280+
resp, err := client.Get(checkURL)
281+
if err != nil {
282+
return
283+
}
284+
defer resp.Body.Close()
285+
286+
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*100))
287+
if err != nil {
288+
return
289+
}
290+
bodyStr := string(body)
291+
292+
for _, pattern := range databaseErrorPatterns {
293+
if pattern.pattern.MatchString(bodyStr) {
294+
mu.Lock()
295+
// check if we already have this error for this URL
296+
found := false
297+
for _, existing := range result.DatabaseErrors {
298+
if existing.URL == checkURL && existing.DatabaseType == pattern.databaseType {
299+
found = true
300+
break
301+
}
302+
}
303+
if !found {
304+
dbError := SQLDatabaseError{
305+
URL: checkURL,
306+
DatabaseType: pattern.databaseType,
307+
ErrorPattern: pattern.pattern.String(),
308+
}
309+
result.DatabaseErrors = append(result.DatabaseErrors, dbError)
310+
311+
sqllog.Warnf("Database error disclosure: %s at [%s]",
312+
styles.SeverityHigh.Render(pattern.databaseType),
313+
styles.Highlight.Render(checkURL))
314+
315+
if logdir != "" {
316+
logger.Write(sanitizedURL, logdir, fmt.Sprintf("Database error disclosure: %s at [%s]\n", pattern.databaseType, checkURL))
317+
}
318+
}
319+
mu.Unlock()
320+
break // only report one database type per URL
321+
}
322+
}
323+
}

0 commit comments

Comments
 (0)