Harden scan robustness and tests

This commit is contained in:
ZacharyZcR
2026-06-14 22:23:48 +08:00
parent 5ad914a1bb
commit c49c23c7f0
100 changed files with 4483 additions and 412 deletions
+255 -23
View File
@@ -4,12 +4,17 @@ package services
import (
"context"
"crypto/hmac"
"crypto/md5"
"crypto/rand"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"fmt"
"io"
"math"
"net"
"strconv"
"strings"
"sync/atomic"
"time"
@@ -17,6 +22,7 @@ import (
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/plugins"
"golang.org/x/crypto/pbkdf2"
)
// MongoDBPlugin MongoDB扫描插件(纯 raw TCP 实现,无重型依赖)
@@ -108,12 +114,13 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo
// Step 2: saslStart SCRAM-SHA-1
nonce := randomString(24)
saslPayload := "n=" + cred.Username + ",r=" + nonce
clientFirstBare := "n=" + cred.Username + ",r=" + nonce
saslPayload := "n,," + clientFirstBare
saslStartBody := mongoDoc{
"saslStart": 1,
"mechanism": "SCRAM-SHA-1",
"payload": base64EncodeStr(saslPayload),
"payload": []byte(saslPayload),
"autoAuthorize": 1,
}
saslStartCmd := buildMongoCommand("admin", saslStartBody)
@@ -127,21 +134,48 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
}
// saslStart 响应检查:
// - ok:0 + code:18 → 认证失败
// - ok:1 + conversationId + payload → 认证有效
respStr := string(resp)
if strings.Contains(respStr, "\"ok\":0") || strings.Contains(respStr, "Authentication failed") {
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed")}
startReply, err := parseMongoCommandReply(resp)
if err != nil {
state.IncrementTCPFailedPacketCount()
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
}
if !startReply.ok {
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed: %s", startReply.errmsg)}
}
if !startReply.conversationSet || len(startReply.payload) == 0 {
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("invalid saslStart response")}
}
// 如果在响应中找到 conversationId,说明凭据有效
if strings.Contains(respStr, "conversationId") {
state.IncrementTCPSuccessPacketCount()
return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil}
serverFirst := string(startReply.payload)
clientFinal, err := buildMongoSCRAMClientFinal(cred.Username, cred.Password, clientFirstBare, serverFirst)
if err != nil {
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err}
}
saslContinueBody := mongoDoc{
"saslContinue": 1,
"conversationId": int(startReply.conversationID),
"payload": []byte(clientFinal),
}
saslContinueCmd := buildMongoCommand("admin", saslContinueBody)
if _, err := sendMongoMsg(ctx, conn, saslContinueCmd, timeout); err != nil {
state.IncrementTCPFailedPacketCount()
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
}
resp, err = readMongoMsg(conn, timeout)
if err != nil {
state.IncrementTCPFailedPacketCount()
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
}
finalReply, err := parseMongoCommandReply(resp)
if err != nil {
state.IncrementTCPFailedPacketCount()
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
}
if !finalReply.ok {
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: fmt.Errorf("authentication failed: %s", finalReply.errmsg)}
}
// 无认证失败的明确信号 = 尝试成功
state.IncrementTCPSuccessPacketCount()
return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil}
}
@@ -149,9 +183,10 @@ func (p *MongoDBPlugin) doMongoDBAuth(ctx context.Context, info *common.HostInfo
// ── MongoDB wire protocol 工具 ──────────────────────────────────
const (
opMsg uint32 = 2013
opQuery uint32 = 2004
opReply uint32 = 1
opMsg uint32 = 2013
opQuery uint32 = 2004
opReply uint32 = 1
maxMongoMessageBody = 1024 * 1024
)
var mongoRequestID uint32
@@ -225,7 +260,7 @@ func buildBSON(doc mongoDoc) []byte {
buf = append(buf, []byte(k)...)
buf = append(buf, 0x00)
b := []byte(val)
buf = append(buf, byte(len(b)+1), 0, 0, 0)
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(b)+1))
buf = append(buf, b...)
buf = append(buf, 0x00)
case int:
@@ -235,13 +270,16 @@ func buildBSON(doc mongoDoc) []byte {
i32 := make([]byte, 4)
binary.LittleEndian.PutUint32(i32, uint32(val))
buf = append(buf, i32...)
case int64:
buf = append(buf, 0x12) // type int64
buf = append(buf, []byte(k)...)
buf = append(buf, 0x00)
buf = binary.LittleEndian.AppendUint64(buf, uint64(val))
case float64:
buf = append(buf, 0x01) // type double
buf = append(buf, []byte(k)...)
buf = append(buf, 0x00)
f64 := make([]byte, 8)
binary.LittleEndian.PutUint64(f64, uint64(val))
buf = append(buf, f64...)
buf = binary.LittleEndian.AppendUint64(buf, math.Float64bits(val))
case mongoDoc:
buf = append(buf, 0x03) // type document
buf = append(buf, []byte(k)...)
@@ -252,7 +290,7 @@ func buildBSON(doc mongoDoc) []byte {
buf = append(buf, 0x05) // type binary
buf = append(buf, []byte(k)...)
buf = append(buf, 0x00)
buf = append(buf, byte(len(val)), 0, 0, 0)
buf = binary.LittleEndian.AppendUint32(buf, uint32(len(val)))
buf = append(buf, 0x00) // subtype 0
buf = append(buf, val...)
case bool:
@@ -301,8 +339,11 @@ func readMongoMsg(conn io.Reader, timeout time.Duration) ([]byte, error) {
}
// 读取剩余 body
bodyLen := int(msgLen) - 16
if bodyLen <= 0 || bodyLen > 1024*1024 {
return nil, nil
if bodyLen == 0 {
return []byte{}, nil
}
if bodyLen > maxMongoMessageBody {
return nil, fmt.Errorf("mongodb response too large: %d", msgLen)
}
body := make([]byte, bodyLen)
if _, err := io.ReadFull(conn, body); err != nil {
@@ -316,6 +357,197 @@ func readMongoMsg(conn io.Reader, timeout time.Duration) ([]byte, error) {
return body, nil
}
type mongoCommandReply struct {
ok bool
conversationID int32
conversationSet bool
payload []byte
done bool
errmsg string
}
func parseMongoCommandReply(doc []byte) (mongoCommandReply, error) {
var reply mongoCommandReply
if len(doc) < 5 {
return reply, fmt.Errorf("short bson document")
}
docLen := int(binary.LittleEndian.Uint32(doc[:4]))
if docLen < 5 || docLen > len(doc) {
return reply, fmt.Errorf("invalid bson document length: %d", docLen)
}
pos := 4
for pos < docLen-1 {
typ := doc[pos]
pos++
keyStart := pos
for pos < docLen && doc[pos] != 0 {
pos++
}
if pos >= docLen {
return reply, fmt.Errorf("unterminated bson key")
}
key := string(doc[keyStart:pos])
pos++
switch typ {
case 0x01: // double
if pos+8 > docLen {
return reply, fmt.Errorf("short bson double")
}
if key == "ok" {
reply.ok = binary.LittleEndian.Uint64(doc[pos:pos+8]) != 0
}
pos += 8
case 0x02: // string
if pos+4 > docLen {
return reply, fmt.Errorf("short bson string length")
}
n := int(binary.LittleEndian.Uint32(doc[pos : pos+4]))
pos += 4
if n <= 0 || pos+n > docLen {
return reply, fmt.Errorf("invalid bson string length: %d", n)
}
value := string(doc[pos : pos+n-1])
pos += n
switch key {
case "errmsg":
reply.errmsg = value
case "payload":
reply.payload = []byte(value)
}
case 0x05: // binary
if pos+5 > docLen {
return reply, fmt.Errorf("short bson binary")
}
n := int(binary.LittleEndian.Uint32(doc[pos : pos+4]))
pos += 5 // length + subtype
if n < 0 || pos+n > docLen {
return reply, fmt.Errorf("invalid bson binary length: %d", n)
}
if key == "payload" {
reply.payload = append([]byte(nil), doc[pos:pos+n]...)
}
pos += n
case 0x03, 0x04: // document, array
if pos+4 > docLen {
return reply, fmt.Errorf("short bson embedded document")
}
n := int(binary.LittleEndian.Uint32(doc[pos : pos+4]))
if n < 5 || pos+n > docLen {
return reply, fmt.Errorf("invalid bson embedded document length: %d", n)
}
pos += n
case 0x07: // objectId
if pos+12 > docLen {
return reply, fmt.Errorf("short bson objectId")
}
pos += 12
case 0x08: // bool
if pos+1 > docLen {
return reply, fmt.Errorf("short bson bool")
}
if key == "done" {
reply.done = doc[pos] != 0
}
if key == "ok" {
reply.ok = doc[pos] != 0
}
pos++
case 0x10: // int32
if pos+4 > docLen {
return reply, fmt.Errorf("short bson int32")
}
value := int32(binary.LittleEndian.Uint32(doc[pos : pos+4]))
if key == "conversationId" {
reply.conversationID = value
reply.conversationSet = true
}
if key == "ok" {
reply.ok = value != 0
}
pos += 4
case 0x09, 0x11: // datetime, timestamp
if pos+8 > docLen {
return reply, fmt.Errorf("short bson fixed64")
}
pos += 8
case 0x0a, 0x7f, 0xff: // null, maxKey, minKey
case 0x12: // int64
if pos+8 > docLen {
return reply, fmt.Errorf("short bson int64")
}
if key == "ok" {
reply.ok = binary.LittleEndian.Uint64(doc[pos:pos+8]) != 0
}
pos += 8
case 0x13: // decimal128
if pos+16 > docLen {
return reply, fmt.Errorf("short bson decimal128")
}
pos += 16
default:
return reply, fmt.Errorf("unsupported bson type 0x%02x for key %s", typ, key)
}
}
return reply, nil
}
func buildMongoSCRAMClientFinal(username, password, clientFirstBare, serverFirst string) (string, error) {
attrs := parseSCRAMAttributes(serverFirst)
serverNonce := attrs["r"]
saltB64 := attrs["s"]
iterText := attrs["i"]
if serverNonce == "" || saltB64 == "" || iterText == "" {
return "", fmt.Errorf("invalid SCRAM server-first payload")
}
clientNonce := scramAttr(clientFirstBare, "r")
if clientNonce == "" || !strings.HasPrefix(serverNonce, clientNonce) {
return "", fmt.Errorf("invalid SCRAM nonce")
}
salt, err := base64.StdEncoding.DecodeString(saltB64)
if err != nil {
return "", fmt.Errorf("invalid SCRAM salt: %w", err)
}
iterations, err := strconv.Atoi(iterText)
if err != nil || iterations <= 0 {
return "", fmt.Errorf("invalid SCRAM iteration count")
}
clientFinalWithoutProof := "c=biws,r=" + serverNonce
authMessage := clientFirstBare + "," + serverFirst + "," + clientFinalWithoutProof
digest := md5.Sum([]byte(username + ":mongo:" + password))
saltedPassword := pbkdf2.Key([]byte(fmt.Sprintf("%x", digest)), salt, iterations, sha1.Size, sha1.New)
clientKey := mongoHMAC(saltedPassword, []byte("Client Key"))
storedKey := sha1.Sum(clientKey)
clientSignature := mongoHMAC(storedKey[:], []byte(authMessage))
proof := make([]byte, len(clientKey))
for i := range clientKey {
proof[i] = clientKey[i] ^ clientSignature[i]
}
return clientFinalWithoutProof + ",p=" + base64.StdEncoding.EncodeToString(proof), nil
}
func parseSCRAMAttributes(payload string) map[string]string {
attrs := make(map[string]string)
for _, part := range strings.Split(payload, ",") {
if len(part) < 3 || part[1] != '=' {
continue
}
attrs[part[:1]] = part[2:]
}
return attrs
}
func scramAttr(payload, key string) string {
return parseSCRAMAttributes(payload)[key]
}
func mongoHMAC(key, data []byte) []byte {
mac := hmac.New(sha1.New, key)
_, _ = mac.Write(data)
return mac.Sum(nil)
}
// dialTCP 带超时的 TCP 连接
func dialTCP(ctx context.Context, addr string, timeout time.Duration) (net.Conn, error) {
dialer := net.Dialer{Timeout: timeout}