Files
fscan/plugins/services/ftp.go
T

287 lines
6.7 KiB
Go

//go:build plugin_ftp || !plugin_selective
package services
import (
"context"
"fmt"
"strings"
"time"
ftplib "github.com/jlaffaye/ftp"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/plugins"
)
// FTPPlugin FTP扫描插件
type FTPPlugin struct {
plugins.BasePlugin
}
func NewFTPPlugin() *FTPPlugin {
return &FTPPlugin{
BasePlugin: plugins.NewBasePlugin("ftp"),
}
}
func (p *FTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
config := session.Config
state := session.State
if config.DisableBrute {
return p.identifyService(info, session)
}
target := info.Target()
// 优先检测匿名访问
if result := p.testAnonymousAccess(ctx, info, session); result != nil && result.Success {
return result
}
credentials := GenerateCredentials("ftp", config)
if len(credentials) == 0 {
return &ScanResult{
Success: false,
Service: "ftp",
Error: fmt.Errorf("%s", i18n.GetText("service_no_credentials")),
}
}
// 使用公共框架进行并发凭据测试
authFn := p.createAuthFunc(info, config, state)
testConfig := DefaultConcurrentTestConfigWithTarget(config, info)
result := TestCredentialsConcurrently(ctx, credentials, authFn, "ftp", testConfig)
if result.Success {
// 成功后重新连接获取文件列表
fileList := p.getFileListAfterAuth(info, result.Username, result.Password, config, state)
var output strings.Builder
fmt.Fprintf(&output, "FTP %s %s:%s", target, result.Username, result.Password)
if len(fileList) > 0 {
for _, file := range fileList {
fmt.Fprintf(&output, "\n [->] %s", file)
}
}
session.LogVuln(output.String())
}
return result
}
// createAuthFunc 创建FTP认证函数
func (p *FTPPlugin) createAuthFunc(info *common.HostInfo, config *common.Config, state *common.State) AuthFunc {
return func(ctx context.Context, cred Credential) *AuthResult {
return p.doFTPAuth(ctx, info, cred, config, state)
}
}
// doFTPAuth 执行FTP认证
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, ftpDialOptions(ctx, config.Timeout)...)
if err != nil {
state.IncrementTCPFailedPacketCount()
return &AuthResult{
Success: false,
ErrorType: classifyFTPErrorType(err),
Error: err,
}
}
state.IncrementTCPSuccessPacketCount()
stopCancelClose := context.AfterFunc(ctx, func() {
_ = conn.Quit()
})
defer stopCancelClose()
err = conn.Login(cred.Username, cred.Password)
if err != nil {
_ = conn.Quit()
return &AuthResult{
Success: false,
ErrorType: classifyFTPErrorType(err),
Error: err,
}
}
return &AuthResult{
Success: true,
Conn: &ftpConnWrapper{conn},
ErrorType: ErrorTypeUnknown,
Error: nil,
}
}
// ftpConnWrapper 包装 ftplib.ServerConn 以实现 io.Closer
type ftpConnWrapper struct {
*ftplib.ServerConn
}
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 {
return ErrorTypeUnknown
}
ftpAuthErrors := []string{
"530 login incorrect",
"530 not logged in",
"530 user cannot log in",
"530 authentication failed",
"authentication failed",
"permission denied",
"access denied",
"invalid credentials",
"bad password",
"login incorrect",
}
ftpNetworkErrors := append(CommonNetworkErrors,
"421 there are too many connections",
)
return ClassifyError(err, ftpAuthErrors, ftpNetworkErrors)
}
func (p *FTPPlugin) identifyService(info *common.HostInfo, session *common.ScanSession) *ScanResult {
config := session.Config
state := session.State
target := info.Target()
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout))
if err != nil {
state.IncrementTCPFailedPacketCount()
return &ScanResult{
Success: false,
Service: "ftp",
Error: err,
}
}
state.IncrementTCPSuccessPacketCount()
defer func() { _ = conn.Quit() }()
banner := "FTP"
session.LogSuccess(i18n.Tr("ftp_service", target, banner))
return &ScanResult{
Type: plugins.ResultTypeService,
Success: true,
Service: "ftp",
Banner: banner,
}
}
// testAnonymousAccess 测试FTP匿名访问
func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
config := session.Config
state := session.State
target := info.Target()
anonymousCreds := []Credential{
{Username: "anonymous", Password: "anonymous"},
{Username: "anonymous", Password: ""},
{Username: "ftp", Password: "ftp"},
}
for _, cred := range anonymousCreds {
select {
case <-ctx.Done():
return nil
default:
}
result := p.doFTPAuth(ctx, info, cred, config, state)
if result.Success && result.Conn != nil {
// 获取文件列表
ftpConn, ok := result.Conn.(*ftpConnWrapper)
if !ok {
_ = result.Conn.Close()
return nil
}
fileList := p.listFTPFiles(ftpConn.ServerConn)
_ = result.Conn.Close()
var output strings.Builder
output.WriteString(i18n.Tr("ftp_anonymous_access_detail", target, cred.Username, cred.Password))
if len(fileList) > 0 {
for _, file := range fileList {
fmt.Fprintf(&output, "\n [->] %s", file)
}
}
session.LogVuln(output.String())
return &ScanResult{
Type: plugins.ResultTypeCredential,
Success: true,
Service: "ftp",
Username: cred.Username,
Password: cred.Password,
Banner: i18n.GetText("ftp_anonymous_banner"),
}
}
}
return nil
}
// getFileListAfterAuth 认证成功后获取文件列表
func (p *FTPPlugin) getFileListAfterAuth(info *common.HostInfo, username, password string, config *common.Config, state *common.State) []string {
target := info.Target()
conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout))
if err != nil {
return nil
}
err = conn.Login(username, password)
if err != nil {
_ = conn.Quit()
return nil
}
fileList := p.listFTPFiles(conn)
_ = conn.Quit()
return fileList
}
// listFTPFiles 列出FTP文件列表(前6个)
func (p *FTPPlugin) listFTPFiles(conn *ftplib.ServerConn) []string {
files := []string{}
entries, err := conn.List(".")
if err != nil {
return files
}
maxFiles := 6
for i, entry := range entries {
if i >= maxFiles {
break
}
fileName := entry.Name
files = append(files, truncateRunes(fileName, 50))
}
return files
}
func init() {
RegisterPluginWithPorts("ftp", func() Plugin {
return NewFTPPlugin()
}, []int{21, 2121, 990})
}