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

功能特性:
- 支持jlaffaye/ftp驱动的FTP协议通信
- 智能识别vsFTPd、ProFTPD等多种FTP服务器
- 三种利用方法:目录结构探测、文件操作测试
- 完整的错误处理和连接限制处理机制
2025-08-08 04:46:35 +08:00

331 lines
9.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}