feat: 实现FTP文件传输协议专业扫描插件

• 新增FTP插件支持标准21端口和常见替代端口
• 实现匿名访问检测和弱密码爆破功能
• 支持自动利用:目录枚举、文件下载测试、文件上传测试
• 集成-nobr模式服务识别和-ne自动利用功能
• 完整的Context超时机制和连接数管理
• 添加中英文国际化消息支持
• 基于新插件架构实现模块化设计

功能特性:
- 支持jlaffaye/ftp驱动的FTP协议通信
- 智能识别vsFTPd、ProFTPD等多种FTP服务器
- 三种利用方法:目录结构探测、文件操作测试
- 完整的错误处理和连接限制处理机制
This commit is contained in:
ZacharyZcR 2025-08-08 04:46:35 +08:00
parent 516225a11f
commit 83afd0f994
3 changed files with 713 additions and 0 deletions

View File

@ -0,0 +1,116 @@
package ftp
import (
"context"
"fmt"
"time"
ftplib "github.com/jlaffaye/ftp"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/plugins/base"
)
// FTPConnector FTP连接器实现
type FTPConnector struct {
host string
port string
}
// NewFTPConnector 创建FTP连接器
func NewFTPConnector() *FTPConnector {
return &FTPConnector{}
}
// Connect 连接到FTP服务
func (c *FTPConnector) Connect(ctx context.Context, info *common.HostInfo) (interface{}, error) {
c.host = info.Host
c.port = info.Ports
// 构建连接地址
target := fmt.Sprintf("%s:%s", c.host, c.port)
// 创建FTP连接配置
config := &FTPConfig{
Target: target,
Timeout: time.Duration(common.Timeout) * time.Second,
}
return config, nil
}
// Authenticate 认证
func (c *FTPConnector) Authenticate(ctx context.Context, conn interface{}, cred *base.Credential) error {
config, ok := conn.(*FTPConfig)
if !ok {
return fmt.Errorf("无效的连接类型")
}
// 在goroutine中建立FTP连接支持Context取消
resultChan := make(chan struct {
ftpConn *ftplib.ServerConn
err error
}, 1)
go func() {
ftpConn, err := ftplib.DialTimeout(config.Target, config.Timeout)
select {
case <-ctx.Done():
if ftpConn != nil {
ftpConn.Quit()
}
case resultChan <- struct {
ftpConn *ftplib.ServerConn
err error
}{ftpConn, err}:
}
}()
// 等待连接结果或Context取消
var ftpConn *ftplib.ServerConn
var err error
select {
case result := <-resultChan:
ftpConn, err = result.ftpConn, result.err
if err != nil {
return fmt.Errorf("FTP连接失败: %v", err)
}
case <-ctx.Done():
return fmt.Errorf("FTP连接超时: %v", ctx.Err())
}
defer ftpConn.Quit()
// 在goroutine中进行登录认证
loginChan := make(chan error, 1)
go func() {
err := ftpConn.Login(cred.Username, cred.Password)
select {
case <-ctx.Done():
case loginChan <- err:
}
}()
// 等待登录结果或Context取消
select {
case err := <-loginChan:
if err != nil {
return fmt.Errorf("FTP认证失败: %v", err)
}
return nil
case <-ctx.Done():
return fmt.Errorf("FTP认证超时: %v", ctx.Err())
}
}
// Close 关闭连接
func (c *FTPConnector) Close(conn interface{}) error {
// FTP配置无需显式关闭
return nil
}
// FTPConfig FTP连接配置
type FTPConfig struct {
Target string
Timeout time.Duration
}

View File

@ -0,0 +1,331 @@
package ftp
import (
"context"
"fmt"
"path/filepath"
"strings"
"time"
ftplib "github.com/jlaffaye/ftp"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/plugins/base"
)
// FTPExploiter FTP利用器
type FTPExploiter struct {
*base.BaseExploiter
connector *FTPConnector
}
// NewFTPExploiter 创建FTP利用器
func NewFTPExploiter() *FTPExploiter {
exploiter := &FTPExploiter{
BaseExploiter: base.NewBaseExploiter("ftp"),
connector: NewFTPConnector(),
}
// 添加利用方法
exploiter.setupExploitMethods()
return exploiter
}
// setupExploitMethods 设置利用方法
func (e *FTPExploiter) setupExploitMethods() {
// 1. 目录枚举
dirMethod := base.NewExploitMethod(base.ExploitDataExtraction, "directory_enumeration").
WithDescription(i18n.GetText("exploit_method_name_directory_enumeration")).
WithPriority(9).
WithConditions("has_credentials").
WithHandler(e.exploitDirectoryEnumeration).
Build()
e.AddExploitMethod(dirMethod)
// 2. 文件下载测试
downloadMethod := base.NewExploitMethod(base.ExploitDataExtraction, "file_download_test").
WithDescription(i18n.GetText("exploit_method_name_file_read")).
WithPriority(8).
WithConditions("has_credentials").
WithHandler(e.exploitFileDownloadTest).
Build()
e.AddExploitMethod(downloadMethod)
// 3. 文件上传测试
uploadMethod := base.NewExploitMethod(base.ExploitFileWrite, "file_upload_test").
WithDescription(i18n.GetText("exploit_method_name_file_write")).
WithPriority(7).
WithConditions("has_credentials").
WithHandler(e.exploitFileUploadTest).
Build()
e.AddExploitMethod(uploadMethod)
}
// exploitDirectoryEnumeration 目录枚举
func (e *FTPExploiter) exploitDirectoryEnumeration(ctx context.Context, info *common.HostInfo, creds *base.Credential) (*base.ExploitResult, error) {
return e.executeWithConnection(ctx, info, creds, "directory_enumeration", e.directoryEnumeration)
}
// exploitFileDownloadTest 文件下载测试
func (e *FTPExploiter) exploitFileDownloadTest(ctx context.Context, info *common.HostInfo, creds *base.Credential) (*base.ExploitResult, error) {
return e.executeWithConnection(ctx, info, creds, "file_download_test", e.fileDownloadTest)
}
// exploitFileUploadTest 文件上传测试
func (e *FTPExploiter) exploitFileUploadTest(ctx context.Context, info *common.HostInfo, creds *base.Credential) (*base.ExploitResult, error) {
return e.executeWithConnection(ctx, info, creds, "file_upload_test", e.fileUploadTest)
}
// executeWithConnection 使用FTP连接执行利用方法
func (e *FTPExploiter) executeWithConnection(ctx context.Context, info *common.HostInfo, creds *base.Credential, methodName string, method func(context.Context, *ftplib.ServerConn, string) ([]string, error)) (*base.ExploitResult, error) {
target := fmt.Sprintf("%s:%s", info.Host, info.Ports)
// 建立FTP连接
ftpConn, err := ftplib.DialTimeout(target, time.Duration(common.Timeout)*time.Second)
if err != nil {
return nil, fmt.Errorf("连接失败: %v", err)
}
defer ftpConn.Quit()
// 登录认证
err = ftpConn.Login(creds.Username, creds.Password)
if err != nil {
return nil, fmt.Errorf("认证失败: %v", err)
}
// 执行方法
output, err := method(ctx, ftpConn, target)
if err != nil {
return &base.ExploitResult{
Success: false,
Error: err,
Type: base.ExploitDataExtraction,
Method: methodName,
Output: fmt.Sprintf("执行失败: %v", err),
}, nil
}
return &base.ExploitResult{
Success: true,
Type: base.ExploitDataExtraction,
Method: methodName,
Output: strings.Join(output, "\n"),
}, nil
}
// directoryEnumeration 目录枚举
func (e *FTPExploiter) directoryEnumeration(ctx context.Context, ftpConn *ftplib.ServerConn, target string) ([]string, error) {
var output []string
// 获取当前工作目录
currentDir, err := ftpConn.CurrentDir()
if err != nil {
currentDir = "/"
}
output = append(output, fmt.Sprintf("当前工作目录: %s", currentDir))
// 列举根目录内容
entries, err := ftpConn.List("")
if err != nil {
return nil, fmt.Errorf("列举目录失败: %v", err)
}
if len(entries) == 0 {
output = append(output, "目录为空")
return output, nil
}
// 显示目录内容(限制显示数量)
maxDisplay := 10
if len(entries) > maxDisplay {
output = append(output, fmt.Sprintf("发现 %d 个条目,显示前 %d 个:", len(entries), maxDisplay))
} else {
output = append(output, fmt.Sprintf("发现 %d 个条目:", len(entries)))
}
dirCount := 0
fileCount := 0
for i, entry := range entries {
if i >= maxDisplay {
break
}
// 格式化文件/目录信息
entryType := "文件"
if entry.Type == ftplib.EntryTypeFolder {
entryType = "目录"
dirCount++
} else {
fileCount++
}
// 格式化文件大小
sizeInfo := ""
if entry.Type != ftplib.EntryTypeFolder {
if entry.Size > 1024*1024 {
sizeInfo = fmt.Sprintf("%.2fMB", float64(entry.Size)/(1024*1024))
} else if entry.Size > 1024 {
sizeInfo = fmt.Sprintf("%.2fKB", float64(entry.Size)/1024)
} else {
sizeInfo = fmt.Sprintf("%dB", entry.Size)
}
}
// 截断过长的文件名
name := entry.Name
if len(name) > 50 {
name = name[:50] + "..."
}
if sizeInfo != "" {
output = append(output, fmt.Sprintf(" [%s] %s (%s)", entryType, name, sizeInfo))
} else {
output = append(output, fmt.Sprintf(" [%s] %s", entryType, name))
}
}
// 添加统计信息
if len(entries) > maxDisplay {
output = append(output, fmt.Sprintf("... 还有 %d 个条目未显示", len(entries)-maxDisplay))
}
output = append(output, fmt.Sprintf("统计: %d个目录, %d个文件", dirCount, fileCount))
return output, nil
}
// fileDownloadTest 文件下载测试
func (e *FTPExploiter) fileDownloadTest(ctx context.Context, ftpConn *ftplib.ServerConn, target string) ([]string, error) {
var output []string
// 获取目录列表
entries, err := ftpConn.List("")
if err != nil {
return nil, fmt.Errorf("获取文件列表失败: %v", err)
}
// 寻找可下载的小文件进行测试
var testFile *ftplib.Entry
for _, entry := range entries {
if entry.Type == ftplib.EntryTypeFile && entry.Size < 10240 { // 小于10KB的文件
// 过滤一些常见的安全文件
name := strings.ToLower(entry.Name)
if !strings.Contains(name, "passwd") &&
!strings.Contains(name, "shadow") &&
!strings.Contains(name, "key") &&
!strings.Contains(name, "config") {
testFile = entry
break
}
}
}
if testFile == nil {
output = append(output, "未找到合适的测试文件寻找小于10KB的普通文件")
return output, nil
}
// 尝试下载测试文件
response, err := ftpConn.Retr(testFile.Name)
if err != nil {
return nil, fmt.Errorf("下载文件失败: %v", err)
}
defer response.Close()
// 读取前1024字节作为测试
buffer := make([]byte, 1024)
n, _ := response.Read(buffer)
output = append(output, fmt.Sprintf("成功下载测试文件: %s", testFile.Name))
output = append(output, fmt.Sprintf("文件大小: %d 字节", testFile.Size))
if n > 0 {
// 检查是否为文本内容
if isPrintableText(buffer[:n]) {
preview := string(buffer[:n])
if len(preview) > 200 {
preview = preview[:200] + "..."
}
output = append(output, fmt.Sprintf("文件内容预览: %s", preview))
} else {
output = append(output, "文件为二进制内容")
}
}
return output, nil
}
// fileUploadTest 文件上传测试
func (e *FTPExploiter) fileUploadTest(ctx context.Context, ftpConn *ftplib.ServerConn, target string) ([]string, error) {
var output []string
// 测试文件内容
testFileName := "fscan_test.txt"
testContent := "This is a test file created by fscan for FTP upload testing."
// 尝试上传测试文件
err := ftpConn.Stor(testFileName, strings.NewReader(testContent))
if err != nil {
// 尝试上传到常见的上传目录
uploadDirs := []string{"upload", "uploads", "tmp", "temp", "public"}
uploaded := false
for _, dir := range uploadDirs {
testPath := filepath.Join(dir, testFileName)
err = ftpConn.Stor(testPath, strings.NewReader(testContent))
if err == nil {
output = append(output, fmt.Sprintf("文件上传成功: %s", testPath))
// 尝试删除测试文件
if deleteErr := ftpConn.Delete(testPath); deleteErr == nil {
output = append(output, "测试文件已清理")
}
uploaded = true
break
}
}
if !uploaded {
return nil, fmt.Errorf("文件上传失败: %v", err)
}
} else {
output = append(output, fmt.Sprintf("文件上传成功: %s", testFileName))
// 验证文件是否存在
entries, err := ftpConn.List("")
if err == nil {
for _, entry := range entries {
if entry.Name == testFileName {
output = append(output, fmt.Sprintf("验证文件存在,大小: %d 字节", entry.Size))
break
}
}
}
// 尝试删除测试文件
if deleteErr := ftpConn.Delete(testFileName); deleteErr == nil {
output = append(output, "测试文件已清理")
} else {
output = append(output, "警告: 无法删除测试文件,请手动清理")
}
}
output = append(output, "FTP上传功能可用")
return output, nil
}
// isPrintableText 检查字节数组是否为可打印文本
func isPrintableText(data []byte) bool {
if len(data) == 0 {
return false
}
printableCount := 0
for _, b := range data {
if (b >= 32 && b <= 126) || b == '\n' || b == '\r' || b == '\t' {
printableCount++
}
}
// 如果80%以上是可打印字符,认为是文本
return float64(printableCount)/float64(len(data)) > 0.8
}

View File

@ -0,0 +1,266 @@
package ftp
import (
"context"
"fmt"
"net"
"strings"
"time"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/plugins/base"
)
// FTPPlugin FTP插件实现
type FTPPlugin struct {
*base.ServicePlugin
exploiter *FTPExploiter
}
// NewFTPPlugin 创建FTP插件
func NewFTPPlugin() *FTPPlugin {
// 插件元数据
metadata := &base.PluginMetadata{
Name: "ftp",
Version: "2.0.0",
Author: "fscan-team",
Description: "FTP文件传输协议扫描和利用插件",
Category: "service",
Ports: []int{21, 2121}, // 21: 标准FTP端口, 2121: 常见替代端口
Protocols: []string{"tcp"},
Tags: []string{"ftp", "file_transfer", "bruteforce", "anonymous"},
}
// 创建连接器和服务插件
connector := NewFTPConnector()
servicePlugin := base.NewServicePlugin(metadata, connector)
// 创建FTP插件
plugin := &FTPPlugin{
ServicePlugin: servicePlugin,
exploiter: NewFTPExploiter(),
}
// 设置能力
plugin.SetCapabilities([]base.Capability{
base.CapWeakPassword,
base.CapUnauthorized,
base.CapDataExtraction,
base.CapFileUpload,
base.CapFileWrite,
})
return plugin
}
// Scan 重写扫描方法以支持匿名登录检测和自动利用
func (p *FTPPlugin) Scan(ctx context.Context, info *common.HostInfo) (*base.ScanResult, error) {
// 如果禁用暴力破解,只进行服务识别
if common.DisableBrute {
return p.performServiceIdentification(ctx, info)
}
// 首先尝试匿名登录
anonymousCred := &base.Credential{
Username: "anonymous",
Password: "",
}
result, err := p.ScanCredential(ctx, info, anonymousCred)
if err == nil && result.Success {
target := fmt.Sprintf("%s:%s", info.Host, info.Ports)
common.LogSuccess(i18n.GetText("ftp_anonymous_success", target))
// 自动利用匿名访问
if !common.DisableExploit {
p.autoExploit(context.Background(), info, anonymousCred)
}
return result, nil
}
// 执行基础的密码扫描
result, err = p.ServicePlugin.Scan(ctx, info)
if err != nil || !result.Success {
return result, err
}
// 记录成功的弱密码发现
target := fmt.Sprintf("%s:%s", info.Host, info.Ports)
cred := result.Credentials[0]
common.LogSuccess(i18n.GetText("ftp_weak_pwd_success", target, cred.Username, cred.Password))
// 自动利用功能(可通过-ne参数禁用
if result.Success && len(result.Credentials) > 0 && !common.DisableExploit {
p.autoExploit(context.Background(), info, result.Credentials[0])
}
return result, nil
}
// generateCredentials 生成FTP凭据
func (p *FTPPlugin) generateCredentials() []*base.Credential {
// 获取FTP专用的用户名字典
usernames := common.Userdict["ftp"]
if len(usernames) == 0 {
// 默认FTP用户名
usernames = []string{"ftp", "ftpuser", "admin", "test", "user", "guest"}
}
return base.GenerateCredentials(usernames, common.Passwords)
}
// autoExploit 自动利用功能
func (p *FTPPlugin) autoExploit(ctx context.Context, info *common.HostInfo, creds *base.Credential) {
target := fmt.Sprintf("%s:%s", info.Host, info.Ports)
common.LogDebug(i18n.GetText("plugin_exploit_start", "FTP", target))
// 执行利用操作
result, err := p.exploiter.Exploit(ctx, info, creds)
if err != nil {
common.LogError(i18n.GetText("plugin_exploit_failed", "FTP", err))
return
}
// 处理利用结果
if result != nil && result.Success {
// SaveExploitResult会自动使用LogSuccess显示红色利用成功消息
base.SaveExploitResult(info, result, "FTP")
}
}
// Exploit 使用exploiter执行利用
func (p *FTPPlugin) Exploit(ctx context.Context, info *common.HostInfo, creds *base.Credential) (*base.ExploitResult, error) {
return p.exploiter.Exploit(ctx, info, creds)
}
// GetExploitMethods 获取利用方法
func (p *FTPPlugin) GetExploitMethods() []base.ExploitMethod {
return p.exploiter.GetExploitMethods()
}
// IsExploitSupported 检查利用支持
func (p *FTPPlugin) IsExploitSupported(method base.ExploitType) bool {
return p.exploiter.IsExploitSupported(method)
}
// performServiceIdentification 执行FTP服务识别-nobr模式
func (p *FTPPlugin) performServiceIdentification(ctx context.Context, info *common.HostInfo) (*base.ScanResult, error) {
target := fmt.Sprintf("%s:%s", info.Host, info.Ports)
// 尝试连接到FTP服务获取Banner
ftpInfo, isFTP := p.identifyFTPService(ctx, info)
if isFTP {
// 记录服务识别成功
common.LogSuccess(i18n.GetText("ftp_service_identified", target, ftpInfo))
return &base.ScanResult{
Success: true,
Service: "FTP",
Banner: ftpInfo,
Extra: map[string]interface{}{
"service": "FTP",
"port": info.Ports,
"info": ftpInfo,
},
}, nil
}
// 如果无法识别为FTP返回失败
return &base.ScanResult{
Success: false,
Error: fmt.Errorf("无法识别为FTP服务"),
}, nil
}
// identifyFTPService 通过Banner识别FTP服务
func (p *FTPPlugin) identifyFTPService(ctx context.Context, info *common.HostInfo) (string, bool) {
target := fmt.Sprintf("%s:%s", info.Host, info.Ports)
// 尝试建立TCP连接
conn, err := net.DialTimeout("tcp", target, time.Duration(common.Timeout)*time.Second)
if err != nil {
return "", false
}
defer conn.Close()
// 设置读取超时
conn.SetReadDeadline(time.Now().Add(time.Duration(common.Timeout) * time.Second))
// FTP服务器在连接后会发送Welcome Banner
banner := make([]byte, 1024)
n, err := conn.Read(banner)
if err != nil || n < 3 {
return "", false
}
bannerStr := strings.TrimSpace(string(banner[:n]))
// 检查FTP协议标识
if strings.HasPrefix(bannerStr, "220") {
// FTP服务器通常以220开头发送welcome消息
// 提取FTP服务器信息
lines := strings.Split(bannerStr, "\n")
if len(lines) > 0 {
firstLine := strings.TrimSpace(lines[0])
// 移除状态码
if len(firstLine) > 4 && firstLine[:3] == "220" {
serverInfo := strings.TrimSpace(firstLine[3:])
// 移除可能的连字符
if len(serverInfo) > 0 && serverInfo[0] == '-' {
serverInfo = strings.TrimSpace(serverInfo[1:])
}
if serverInfo != "" {
return fmt.Sprintf("FTP服务: %s", serverInfo), true
}
}
}
return "FTP服务", true
}
// 检查其他可能的FTP响应
lowerBanner := strings.ToLower(bannerStr)
if strings.Contains(lowerBanner, "ftp") ||
strings.Contains(lowerBanner, "file transfer") ||
strings.Contains(lowerBanner, "vsftpd") ||
strings.Contains(lowerBanner, "proftpd") ||
strings.Contains(lowerBanner, "pure-ftpd") {
return fmt.Sprintf("FTP服务: %s", bannerStr), true
}
return "", false
}
// =============================================================================
// 插件注册
// =============================================================================
// RegisterFTPPlugin 注册FTP插件
func RegisterFTPPlugin() {
factory := base.NewSimplePluginFactory(
&base.PluginMetadata{
Name: "ftp",
Version: "2.0.0",
Author: "fscan-team",
Description: "FTP文件传输协议扫描和利用插件",
Category: "service",
Ports: []int{21, 2121}, // 21: 标准FTP端口, 2121: 常见替代端口
Protocols: []string{"tcp"},
Tags: []string{"ftp", "file_transfer", "bruteforce", "anonymous"},
},
func() base.Plugin {
return NewFTPPlugin()
},
)
base.GlobalPluginRegistry.Register("ftp", factory)
}
// 自动注册
func init() {
RegisterFTPPlugin()
}