MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Redis EVALSHA命令实现的安全漏洞防范

2021-05-162.9k 阅读

Redis EVALSHA 命令概述

Redis 的 EVALSHA 命令允许用户通过给定的 SHA1 校验和来执行 Lua 脚本。它是 EVAL 命令的优化版本,EVAL 命令每次执行都需要传递完整的 Lua 脚本内容,而 EVALSHA 只需传递之前已在 Redis 服务器上注册过的脚本的 SHA1 校验和。这样做的好处在于,对于频繁执行的相同脚本,避免了重复传输脚本内容,节省了网络带宽,也提高了执行效率。

例如,假设我们有一个简单的 Lua 脚本用于递增 Redis 中的某个键的值:

local key = KEYS[1]
local incr_amount = ARGV[1]
redis.call('INCRBY', key, incr_amount)
return redis.call('GET', key)

首先,我们可以使用 EVAL 命令执行这个脚本:

redis-cli EVAL "local key = KEYS[1] local incr_amount = ARGV[1] redis.call('INCRBY', key, incr_amount) return redis.call('GET', key)" 1 mykey 5

这里的 1 表示后面跟着 1 个键(即 mykey),5 是传递给脚本的参数。

而使用 EVALSHA 命令时,需要先将脚本加载到 Redis 服务器并获取其 SHA1 校验和:

redis-cli SCRIPT LOAD "local key = KEYS[1] local incr_amount = ARGV[1] redis.call('INCRBY', key, incr_amount) return redis.call('GET', key)"

Redis 会返回该脚本的 SHA1 校验和,例如 2f5c359c7c8c92a91b75c26c6c3d96c2995b1c29。然后我们就可以使用 EVALSHA 命令通过这个 SHA1 校验和来执行脚本:

redis-cli EVALSHA 2f5c359c7c8c92a91b75c26c6c3d96c2995b1c29 1 mykey 5

潜在安全漏洞

  1. 脚本注入漏洞 虽然 EVALSHA 命令的设计初衷是为了安全高效地执行 Lua 脚本,但如果在生成 SHA1 校验和或传递参数的过程中处理不当,仍然可能出现脚本注入漏洞。例如,当脚本的参数来自不可信的用户输入时,如果没有进行适当的验证和转义,攻击者可能会构造恶意参数来改变脚本的逻辑。 假设我们有如下一个 Lua 脚本用于获取键的值:
local key = KEYS[1]
return redis.call('GET', key)

正常情况下,我们使用 EVALSHA 执行:

redis-cli SCRIPT LOAD "local key = KEYS[1] return redis.call('GET', key)"

得到 SHA1 校验和后执行:

redis-cli EVALSHA <sha1sum> 1 mykey

然而,如果在传递键名时没有对用户输入进行验证,攻击者可以构造恶意的键名。比如,攻击者输入 mykey'; redis.call('DEL', 'important_key') --,如果代码没有对这个输入进行处理,脚本实际执行的逻辑就会变为:

local key = "mykey'; redis.call('DEL', 'important_key') --"
return redis.call('GET', key)

这样,不仅会获取错误的键值,还会删除 important_key,导致数据丢失或系统异常。

  1. 未注册脚本执行漏洞 在使用 EVALSHA 时,如果 SHA1 校验和对应的脚本未在 Redis 服务器上注册,Redis 会返回一个错误。但在某些情况下,例如在分布式系统中,不同节点之间可能存在脚本注册的不一致性。如果一个节点上没有注册某个脚本,但另一个节点上有,并且错误处理不当,可能会导致未注册脚本的意外执行。 假设在一个分布式 Redis 环境中,节点 A 上注册了一个脚本并获取了其 SHA1 校验和,而节点 B 上没有注册。应用程序在节点 A 上获取 SHA1 后,尝试在节点 B 上执行 EVALSHA 命令。如果节点 B 没有正确处理这种未注册脚本的情况,可能会导致错误的行为,甚至是恶意脚本的执行(如果攻击者能够控制在某个节点上注册恶意脚本并诱导其他节点执行)。

  2. 脚本版本不一致漏洞 在开发和运维过程中,可能会对 Lua 脚本进行更新。如果在更新脚本后没有正确处理 SHA1 校验和的变化,可能会导致使用旧的 SHA1 校验和执行了新的脚本,或者新的 SHA1 校验和执行了旧的脚本。这可能会引发逻辑错误,甚至安全问题。 例如,原脚本是简单的获取键值:

local key = KEYS[1]
return redis.call('GET', key)

更新后的脚本增加了权限验证逻辑:

local key = KEYS[1]
local user_role = ARGV[1]
if user_role ~= 'admin' then
    return nil
end
return redis.call('GET', key)

如果在更新脚本后,没有更新对应的 SHA1 校验和,仍然使用旧的 SHA1 执行新脚本,就会绕过权限验证逻辑,导致非管理员用户也能获取敏感数据。

安全漏洞防范措施

  1. 输入验证和转义 为了防止脚本注入漏洞,对所有来自用户输入的参数都要进行严格的验证和转义。在 Lua 脚本中,可以使用 Redis 提供的内置函数或自定义函数来实现。 例如,对于键名输入,可以使用 Lua 的字符串匹配函数来验证其格式。假设只允许字母数字组成的键名:
local key = KEYS[1]
if key:match('^%w+$') then
    return redis.call('GET', key)
else
    return nil
end

在应用程序端,也可以对输入进行验证。以 Python 为例,使用 redis - py 库:

import redis
import re

r = redis.Redis(host='localhost', port=6379, db = 0)

def validate_key(key):
    return bool(re.match('^%w+$', key))

user_key = input("请输入键名: ")
if validate_key(user_key):
    script_sha = r.script_load("local key = KEYS[1] return redis.call('GET', key)")
    result = r.evalsha(script_sha, 1, user_key)
    print(result)
else:
    print("无效的键名")
  1. 确保脚本注册一致性 在分布式环境中,要确保所有节点上的脚本注册一致。可以采用以下几种方法:
    • 集中式脚本管理:使用一个集中的配置文件或服务来管理所有 Redis 节点上的脚本。当有新脚本或脚本更新时,通过这个集中管理的方式统一更新所有节点。例如,可以使用一个配置管理工具(如 Ansible)来批量执行 SCRIPT LOAD 命令,确保所有节点都注册了相同版本的脚本。
    • 脚本预加载:在 Redis 节点启动时,自动加载所有需要的脚本。可以在 Redis 配置文件中使用 lua - script - load - file 选项指定要加载的 Lua 脚本文件路径。这样,在节点启动时就会加载并注册这些脚本,避免了运行时由于脚本未注册导致的问题。
    • 错误处理:在执行 EVALSHA 命令时,要正确处理脚本未注册的错误。在应用程序中,可以捕获这个错误并采取相应的措施,比如重新加载脚本并再次执行。以 Java 为例,使用 Jedis 库:
import redis.clients.jedis.Jedis;
import redis.clients.jedis.exceptions.JedisDataException;

public class RedisEvalSHAExample {
    public static void main(String[] args) {
        Jedis jedis = new Jedis("localhost", 6379);
        String script = "local key = KEYS[1] return redis.call('GET', key)";
        String sha1 = jedis.scriptLoad(script);

        try {
            Object result = jedis.evalsha(sha1, 1, "mykey");
            System.out.println(result);
        } catch (JedisDataException e) {
            if (e.getMessage().contains("NOSCRIPT")) {
                // 重新加载脚本并执行
                sha1 = jedis.scriptLoad(script);
                Object result = jedis.evalsha(sha1, 1, "mykey");
                System.out.println(result);
            } else {
                throw e;
            }
        }
        jedis.close();
    }
}
  1. 版本控制与更新管理 为了避免脚本版本不一致导致的问题,需要建立完善的版本控制和更新管理机制。
    • 版本编号:在 Lua 脚本中添加版本编号,例如:
-- 版本 1.0
local key = KEYS[1]
return redis.call('GET', key)

当脚本更新时,更新版本编号。这样在应用程序中可以根据版本编号来决定是否需要重新加载脚本。 - 脚本更新流程:在更新脚本时,要确保所有使用该脚本的地方都更新对应的 SHA1 校验和。可以通过自动化脚本或工具来完成这个过程。例如,在 CI/CD 流程中,当脚本文件发生变化时,自动重新计算 SHA1 校验和并更新应用程序配置文件中的相关部分。 - 验证与测试:在更新脚本后,要进行充分的验证和测试。可以编写单元测试和集成测试来确保新脚本的功能和安全性。以 Python 为例,使用 unittest 库来测试 Lua 脚本的功能:

import unittest
import redis

class RedisScriptTest(unittest.TestCase):
    def setUp(self):
        self.r = redis.Redis(host='localhost', port=6379, db = 0)

    def test_script_function(self):
        script = "local key = KEYS[1] return redis.call('GET', key)"
        sha1 = self.r.script_load(script)
        self.r.set('testkey', 'testvalue')
        result = self.r.evalsha(sha1, 1, 'testkey')
        self.assertEqual(result.decode('utf - 8'), 'testvalue')

if __name__ == '__main__':
    unittest.main()

实际应用场景中的防范策略

  1. Web 应用中的防范 在 Web 应用中,通常会有大量用户输入。以一个简单的用户登录系统为例,假设使用 Redis 来存储用户登录状态。我们可能会有一个 Lua 脚本用于验证用户登录并更新登录时间:
local key = KEYS[1]
local password = ARGV[1]
local current_time = ARGV[2]
local stored_password = redis.call('GET', key)
if stored_password == password then
    redis.call('SET', key, current_time)
    return true
else
    return false
end

在 Web 应用中,对于用户输入的用户名(作为键)和密码,要进行严格验证。可以使用正则表达式验证用户名格式,对密码进行加密处理(这里不涉及脚本内的密码加密,仅强调输入验证)。 在 Python Flask 应用中:

from flask import Flask, request
import redis
import re

app = Flask(__name__)
r = redis.Redis(host='localhost', port=6379, db = 0)

def validate_username(username):
    return bool(re.match('^[a-zA - Z0 - 9_]{3,20}$', username))

@app.route('/login', methods=['POST'])
def login():
    username = request.form.get('username')
    password = request.form.get('password')
    if validate_username(username):
        script_sha = r.script_load("local key = KEYS[1] local password = ARGV[1] local current_time = ARGV[2] local stored_password = redis.call('GET', key) if stored_password == password then redis.call('SET', key, current_time) return true else return false end")
        current_time = '2023 - 10 - 01 12:00:00'
        result = r.evalsha(script_sha, 1, username, password, current_time)
        if result:
            return '登录成功'
        else:
            return '用户名或密码错误'
    else:
        return '无效的用户名'

if __name__ == '__main__':
    app.run(debug=True)
  1. 数据缓存应用中的防范 在数据缓存应用中,经常会使用 Lua 脚本来处理缓存的更新、删除等操作。假设我们有一个缓存新闻文章的系统,使用 Lua 脚本更新文章缓存并记录更新时间:
local key = KEYS[1]
local article_content = ARGV[1]
local update_time = ARGV[2]
redis.call('SET', key, article_content)
redis.call('SET', key.. '_update_time', update_time)
return true

在这种情况下,要确保文章内容和更新时间的输入是可信的。可以对文章内容进行长度限制和内容过滤,防止恶意数据注入。例如,限制文章内容长度不超过 10000 字符,并且过滤掉可能包含恶意脚本的特殊字符。 在 Go 语言中:

package main

import (
    "fmt"
    "github.com/go - redis/redis/v8"
    "strings"
)

var rdb *redis.Client
var ctx = context.Background()

func validateArticleContent(content string) bool {
    if len(content) > 10000 {
        return false
    }
    if strings.ContainsAny(content, "<>'\"&;") {
        return false
    }
    return true
}

func main() {
    rdb = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379",
        Password: "",
        DB:       0,
    })

    articleContent := "这是一篇新闻文章内容"
    updateTime := "2023 - 10 - 01 13:00:00"
    if validateArticleContent(articleContent) {
        script := `local key = KEYS[1] local article_content = ARGV[1] local update_time = ARGV[2] redis.call('SET', key, article_content) redis.call('SET', key.. '_update_time', update_time) return true`
        sha1, err := rdb.ScriptLoad(ctx, script).Result()
        if err!= nil {
            fmt.Println("加载脚本错误:", err)
            return
        }
        result, err := rdb.EvalSha(ctx, sha1, []string{"article_key"}, articleContent, updateTime).Bool()
        if err!= nil {
            fmt.Println("执行脚本错误:", err)
            return
        }
        if result {
            fmt.Println("缓存更新成功")
        }
    } else {
        fmt.Println("无效的文章内容")
    }
}
  1. 分布式任务队列中的防范 在分布式任务队列中,使用 Lua 脚本来处理任务的添加、取出等操作。例如,一个简单的任务队列 Lua 脚本:
local key = KEYS[1]
local task = ARGV[1]
redis.call('RPUSH', key, task)
return redis.call('LLEN', key)

在分布式环境中,要确保所有节点上的这个脚本版本一致。可以通过前面提到的集中式脚本管理或脚本预加载的方式来实现。同时,对于任务内容(即 task)要进行验证,确保其格式正确且不包含恶意指令。 在 Node.js 中,使用 ioredis 库:

const Redis = require('ioredis');
const redis = new Redis(6379, 'localhost');

function validateTask(task) {
    // 简单验证任务是否为字符串且长度合理
    return typeof task ==='string' && task.length < 1000;
}

async function addTask(task) {
    if (validateTask(task)) {
        const script = "local key = KEYS[1] local task = ARGV[1] redis.call('RPUSH', key, task) return redis.call('LLEN', key)";
        const sha1 = await redis.scriptLoad(script);
        const result = await redis.evalsha(sha1, 1, 'task_queue', task);
        console.log('任务添加成功,队列长度:', result);
    } else {
        console.log('无效的任务');
    }
}

const newTask = '这是一个新任务';
addTask(newTask);

总结防范要点

  1. 输入验证是关键 无论是 Web 应用、数据缓存还是分布式任务队列等场景,对来自外部的输入进行严格验证和转义是防范脚本注入漏洞的首要步骤。在 Lua 脚本内部和应用程序端都要进行验证,确保输入的数据符合预期格式,不包含恶意代码。
  2. 脚本一致性不容忽视 在分布式系统中,保证所有 Redis 节点上的脚本注册一致至关重要。可以采用集中式管理、预加载或合理的错误处理机制来避免未注册脚本执行的风险。
  3. 版本控制保障安全稳定 建立完善的脚本版本控制和更新管理机制,通过版本编号、自动化更新流程和充分的验证测试,防止脚本版本不一致导致的逻辑错误和安全问题。
  4. 持续监控与更新 安全防范不是一次性的工作,需要持续监控 Redis 环境中 Lua 脚本的执行情况,及时发现潜在的安全风险。同时,随着业务需求的变化和技术的发展,不断更新和完善防范策略,以适应新的安全挑战。例如,关注 Redis 官方发布的安全补丁和 Lua 脚本执行机制的更新,及时调整应用程序的相关部分,确保系统的安全性和稳定性。
  5. 安全意识培养 开发人员和运维人员都需要具备良好的安全意识,了解 Redis EVALSHA 命令可能存在的安全漏洞以及相应的防范措施。在日常开发和运维过程中,遵循安全最佳实践,将安全融入到整个开发和运维流程中。可以通过定期的安全培训和知识分享,提高团队成员的安全意识和技能水平,共同保障 Redis 环境的安全。

通过以上全面、深入的防范措施,可以有效地降低 Redis EVALSHA 命令实现过程中的安全风险,确保基于 Redis 的应用系统的安全、稳定运行。无论是小型的 Web 应用,还是大型的分布式系统,都能在使用 Redis EVALSHA 命令时,避免潜在的安全漏洞带来的损失。在实际应用中,要根据具体的业务场景和需求,灵活运用这些防范策略,并不断优化和完善,以应对复杂多变的安全环境。同时,持续关注 Redis 技术的发展和安全动态,及时调整防范措施,确保系统始终处于安全可靠的状态。例如,随着 Redis 功能的不断扩展和更新,可能会出现新的安全风险,只有保持对技术的敏锐洞察力,才能及时发现并解决问题,保障系统的安全性和稳定性。