mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-25 04:31:52 +08:00
Harden scan robustness and tests
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user