diff --git a/plugins/services/mongodb.go b/plugins/services/mongodb.go index f61fedc..2fb7d28 100644 --- a/plugins/services/mongodb.go +++ b/plugins/services/mongodb.go @@ -101,7 +101,7 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo defer conn.Close() // Step 1: isMaster 获取服务参数 - isMasterCmd := buildMongoCommand("admin", "isMaster", mongoDoc{}) + isMasterCmd := buildMongoCommand("admin", "isMaster") if _, err := sendMongoMsg(ctx, conn, isMasterCmd, timeout); err != nil { state.IncrementTCPFailedPacketCount() return &AuthResult{Success: false, ErrorType: classifyMongoDBErrorType(err), Error: err} @@ -117,13 +117,12 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo clientFirstBare := "n=" + cred.Username + ",r=" + nonce saslPayload := "n,," + clientFirstBare - saslStartBody := mongoDoc{ - "saslStart": 1, - "mechanism": "SCRAM-SHA-1", - "payload": []byte(saslPayload), - "autoAuthorize": 1, - } - saslStartCmd := buildMongoCommand("admin", saslStartBody) + saslStartCmd := buildMongoCommand("admin", orderedDoc( + kv("saslStart", 1), + kv("mechanism", "SCRAM-SHA-1"), + kv("payload", []byte(saslPayload)), + kv("autoAuthorize", 1), + )) if _, err := sendMongoMsg(ctx, conn, saslStartCmd, timeout); err != nil { state.IncrementTCPFailedPacketCount() return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} @@ -152,12 +151,11 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err} } - saslContinueBody := mongoDoc{ - "saslContinue": 1, - "conversationId": int(startReply.conversationID), - "payload": []byte(clientFinal), - } - saslContinueCmd := buildMongoCommand("admin", saslContinueBody) + saslContinueCmd := buildMongoCommand("admin", orderedDoc( + kv("saslContinue", 1), + kv("conversationId", int(startReply.conversationID)), + kv("payload", []byte(clientFinal)), + )) if _, err := sendMongoMsg(ctx, conn, saslContinueCmd, timeout); err != nil { state.IncrementTCPFailedPacketCount() return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err} @@ -196,64 +194,64 @@ func nextRequestID() uint32 { } // buildMongoCommand 构建 MongoDB 命令的 OP_MSG body (最小 BSON 实现) -// key 为字符串时,构建 {key: value} 作为命令名 -// key 为 map 时,展开所有字段 +// MongoDB 要求命令名是 BSON 文档的第一个键,因此使用 orderedDoc 保证顺序。 func buildMongoCommand(db string, args ...interface{}) []byte { var buf []byte - // flags: 0 (ChecksumPresent=0, MoreToCome=0, ExhaustAllowed=0) - buf = append(buf, 0, 0, 0, 0) - // section kind 0: body - buf = append(buf, 0) + buf = append(buf, 0, 0, 0, 0) // flags + buf = append(buf, 0) // section kind 0: body - // 构建 BSON 文档 - if len(db) > 0 { - // {$db: "admin", ...} - docs := mongoDoc{"$db": db} - for i := 0; i < len(args); i++ { - switch v := args[i].(type) { - case string: - if i+1 < len(args) { - docs[v] = args[i+1] - i++ - } - case mongoDoc: - for k, val := range v { - docs[k] = val - } + var doc []mongoKV + + for i := 0; i < len(args); i++ { + switch v := args[i].(type) { + case string: + if i+1 < len(args) { + doc = append(doc, kv(v, args[i+1])) + i++ + } else { + doc = append(doc, kv(v, 1)) } + case mongoDoc: + for k, val := range v { + doc = append(doc, kv(k, val)) + } + case []mongoKV: + doc = append(doc, v...) } - return append(buf, buildBSON(docs)...) } - // 简单命令: {commandName: 1, $db: "admin"} - if len(args) >= 1 { - docs := mongoDoc{} - if cmdName, ok := args[0].(string); ok { - docs[cmdName] = 1 - } - if len(args) >= 2 { - switch v := args[1].(type) { - case mongoDoc: - for k, val := range v { - docs[k] = val - } - } - } - if db != "" { - docs["$db"] = db - } - return append(buf, buildBSON(docs)...) + if db != "" { + doc = append(doc, kv("$db", db)) } - return buf + return append(buf, buildBSON(doc)...) } -type mongoDoc map[string]interface{} +type mongoDoc = map[string]interface{} -// buildBSON 构建最小 BSON 文档(仅支持 string/int32/double/binary/subdocument) -func buildBSON(doc mongoDoc) []byte { +type mongoKV struct { + Key string + Value interface{} +} + +func orderedDoc(kvs ...mongoKV) []mongoKV { return kvs } +func kv(k string, v interface{}) mongoKV { return mongoKV{k, v} } + +func buildBSON(doc interface{}) []byte { + var pairs []mongoKV + switch d := doc.(type) { + case []mongoKV: + pairs = d + case mongoDoc: + for k, v := range d { + pairs = append(pairs, mongoKV{k, v}) + } + default: + return []byte{5, 0, 0, 0, 0} + } var buf []byte - for k, v := range doc { + for _, p := range pairs { + k, v := p.Key, p.Value switch val := v.(type) { case string: buf = append(buf, 0x02) // type string @@ -284,8 +282,12 @@ func buildBSON(doc mongoDoc) []byte { buf = append(buf, 0x03) // type document buf = append(buf, []byte(k)...) buf = append(buf, 0x00) - sub := buildBSON(val) - buf = append(buf, sub...) + buf = append(buf, buildBSON(val)...) + case []mongoKV: + buf = append(buf, 0x03) // type document + buf = append(buf, []byte(k)...) + buf = append(buf, 0x00) + buf = append(buf, buildBSON(val)...) case []byte: buf = append(buf, 0x05) // type binary buf = append(buf, []byte(k)...) diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml new file mode 100644 index 0000000..6455d9f --- /dev/null +++ b/tests/integration/docker-compose.yml @@ -0,0 +1,89 @@ +services: + redis: + image: redis:7-alpine + command: redis-server --requirepass test123 + ports: + - "16379:6379" + healthcheck: + test: ["CMD", "redis-cli", "-a", "test123", "ping"] + interval: 3s + retries: 10 + + redis-noauth: + image: redis:7-alpine + ports: + - "16380:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + retries: 10 + + mysql: + image: mysql:8.0 + command: --default-authentication-plugin=mysql_native_password + environment: + MYSQL_ROOT_PASSWORD: root123 + MYSQL_ROOT_HOST: "%" + MYSQL_DATABASE: testdb + ports: + - "13307:3306" + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-proot123"] + interval: 5s + retries: 20 + + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres123 + POSTGRES_DB: testdb + ports: + - "15432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 3s + retries: 10 + + ftp: + image: fauria/vsftpd + environment: + FTP_USER: ftpuser + FTP_PASS: ftp123 + PASV_MIN_PORT: 21100 + PASV_MAX_PORT: 21110 + PASV_ADDRESS: 127.0.0.1 + ports: + - "10021:21" + - "21100-21110:21100-21110" + healthcheck: + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/21' || exit 1"] + interval: 5s + retries: 10 + + ssh: + image: lscr.io/linuxserver/openssh-server:latest + environment: + PUID: 1000 + PGID: 1000 + USER_NAME: sshuser + USER_PASSWORD: ssh123 + PASSWORD_ACCESS: "true" + ports: + - "10022:2222" + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 2222 || exit 1"] + interval: 3s + retries: 10 + + mongodb: + image: mongo:4.4 + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: mongo123 + ports: + - "17017:27017" + healthcheck: + test: ["CMD", "mongo", "--eval", "db.adminCommand('ping')", "-u", "admin", "-p", "mongo123"] + interval: 5s + retries: 20 diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go new file mode 100644 index 0000000..f8e03c8 --- /dev/null +++ b/tests/integration/integration_test.go @@ -0,0 +1,222 @@ +//go:build integration + +package integration + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/shadow1ng/fscan/common" + "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/plugins/services" +) + +const ( + testHost = "127.0.0.1" +) + +func testSession() *common.ScanSession { + cfg := common.NewConfig() + cfg.Timeout = 10 * time.Second + cfg.ModuleThreadNum = 5 + cfg.MaxRetries = 2 + cfg.Credentials.Userdict = nil + cfg.Credentials.Passwords = nil + state := common.NewState() + return common.NewScanSession(cfg, state, &common.FlagVars{}) +} + +func hostInfo(host string, port int) *common.HostInfo { + return &common.HostInfo{Host: host, Port: port} +} + +func TestMain(m *testing.M) { + fmt.Println("integration tests: ensure docker-compose services are running") + os.Exit(m.Run()) +} + +// ── Redis ────────────────────────────────────────────────────── + +func TestRedisUnauthorized(t *testing.T) { + session := testSession() + info := hostInfo(testHost, 16380) + plugin := services.NewRedisPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected unauthorized redis to succeed, got error: %v", result.Error) + } + t.Logf("redis noauth: %+v", result) +} + +func TestRedisBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "", Password: "wrong1"}, + {Username: "", Password: "test123"}, + } + info := hostInfo(testHost, 16379) + plugin := services.NewRedisPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected redis brute to succeed with test123, got error: %v", result.Error) + } + if result.Password != "test123" { + t.Errorf("expected password test123, got %q", result.Password) + } + t.Logf("redis brute: %+v", result) +} + +// ── MySQL ────────────────────────────────────────────────────── + +func TestMySQLBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "root", Password: "wrong"}, + {Username: "root", Password: "root123"}, + } + info := hostInfo(testHost, 13307) + plugin := services.NewMySQLPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected mysql brute to succeed, got error: %v", result.Error) + } + t.Logf("mysql brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── PostgreSQL ───────────────────────────────────────────────── + +func TestPostgreSQLBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "postgres", Password: "wrong"}, + {Username: "postgres", Password: "postgres123"}, + } + info := hostInfo(testHost, 15432) + plugin := services.NewPostgreSQLPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected postgresql brute to succeed, got error: %v", result.Error) + } + t.Logf("postgresql brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── FTP ──────────────────────────────────────────────────────── + +func TestFTPBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "ftpuser", Password: "wrong"}, + {Username: "ftpuser", Password: "ftp123"}, + } + info := hostInfo(testHost, 10021) + plugin := services.NewFTPPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected ftp brute to succeed, got error: %v", result.Error) + } + t.Logf("ftp brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── SSH ──────────────────────────────────────────────────────── + +func TestSSHBrute(t *testing.T) { + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "sshuser", Password: "wrong"}, + {Username: "sshuser", Password: "ssh123"}, + } + info := hostInfo(testHost, 10022) + plugin := services.NewSSHPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected ssh brute to succeed, got error: %v", result.Error) + } + t.Logf("ssh brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── MongoDB ──────────────────────────────────────────────────── + +func TestMongoDBBrute(t *testing.T) { + // Fixed: BSON key ordering was non-deterministic (Go map), MongoDB requires command name first + session := testSession() + session.Config.Credentials.UserPassPairs = []config.CredentialPair{ + {Username: "admin", Password: "wrong"}, + {Username: "admin", Password: "mongo123"}, + } + info := hostInfo(testHost, 17017) + plugin := services.NewMongoDBPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result == nil { + t.Fatal("result is nil") + } + if !result.Success { + t.Fatalf("expected mongodb brute to succeed, got error: %v", result.Error) + } + t.Logf("mongodb brute: user=%s pass=%s", result.Username, result.Password) +} + +// ── 连接失败场景 ────────────────────────────────────────────── + +func TestRedisConnectionRefused(t *testing.T) { + session := testSession() + session.Config.Timeout = 3 * time.Second + info := hostInfo(testHost, 19999) + plugin := services.NewRedisPlugin() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := plugin.Scan(ctx, info, session) + if result != nil && result.Success { + t.Fatal("expected failure on closed port") + } +}