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
+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")
}
})
}
}