redis_cmd 内置防注入功能的原理与验证

目录

一.源码查看

二.官方文档说明

1.概述

2.RESP协议描述

3.向Redis服务器发送命令

三.测试验证

1.字符串拼接注入测试

​编辑

2.占位符参数化注入测试

3.RESP协议二进制注入测试

四.结论


在kamailio.cfg.template脚本中,执行redis的命令都是通过redis_cmd来向redis服务器发送的,并且脚本里面都是对原本的命令以及参数都是拼接,所以担心存在注入问题。

我们知道,对于mysql来说,她有一个参数化防注入机制,有专门的"预编译语法",也就是提前固定命令机构,后续绑定参数,而redis是不具备这个机制的。

但是通过查询,redis有RESP解析规则,并且kamailio中也是遵循这个协议来发送redis命令的。RESP协议会按照固定的格式解析"命令名+参数",会把第一个元素当作命令,其余当作这个命令的参数。

这里需要介绍一下RESP:是Redis 客户端与服务器之间的通信协议,RESP 可以序列化不同的数据类型,包括整数、字符串和数组。 它还具有一种针对错误的特定类型。 客户端以字符串数组的形式向 Redis 服务器发送请求。 数组的内容是服务器应执行的命令及其参数。 服务器的回复类型是命令特定的。

(服务端具体怎么通过协议解析命令详见第二部分)

接着通过源码、redis官方文档、实验测试三部分来证明redis_cmd底层的实现已经杜绝了注入问题(即使是拼接)

一.源码查看

kamailio中的redis_cmd底层是实现了参数化传递防注入攻击的。从源码来看,有w_redis_cmd3(cmd4,cmd5.....)->redisc_exec->redisvCommand->redisvAppendCommand->redisvFormatCommand。最终跳转到redisvFormatCommand函数中。

在这个函数里面把命令模板和参数解析成"命令名+独立参数数组",而不是拼接成普通字符串。具体实现是:首先用curargv来独立存储每个参数,遍历c(也就是format)从参数列表里面取出实际参数,把参数内容追加到curarg,所以说在这里是当值存储的,不会解析redis语法,遍历完后,所有参数都会被存入curargv数组,成为独立元素。最后他会遍历每个参数,原样写入不解析任何语法。所以redis服务器收到之后,假设第一个参数SET是命令名,后续的参数中即使有flushall也会被当作值。

下面的代码只是redisvFormatCommand函数的一部分,主要提取了实现 Redis 命令注入防护的核心逻辑片段,完整的函数在下图所示的文件中

cpp 复制代码
/* 
 * 核心功能:将格式化命令字符串解析为Redis RESP协议格式的命令
 * 防注入核心:将所有参数(含恶意字符)封装为RESP协议的"参数数组",而非拼接成可执行的多命令
 * 参数说明:
 *   target - 输出最终的RESP协议命令字符串
 *   format - 命令格式模板(如"SET %s %s")
 *   ap - 可变参数列表(模板中%s/%b对应的实际参数)
 */
int redisvFormatCommand(char **target, const char *format, va_list ap) {
    const char *c = format;       // 遍历格式模板的指针
    char **curargv = NULL;        // 存储拆分后的参数数组(核心防注入载体)
    int argc = 0;                 // 参数数组的长度
    int totlen = 0;               // 最终RESP命令的总长度
    hisds curarg = hi_sdsempty(); // 临时存储当前解析的参数
    int touched = 0;              // 标记当前参数是否有内容

    if (target == NULL) return -1;
    if (curarg == NULL) return -1;

    /* ========== 核心步骤1:解析格式模板,拆分参数(防注入关键) ========== */
    while(*c != '\0') {
        // 非占位符(%)的普通字符:逐字符拼接为当前参数
        if (*c != '%' || c[1] == '\0') {
            // 遇到空格:结束当前参数,存入参数数组(按空格拆分参数)
            if (*c == ' ') {
                if (touched) {
                    // 扩容参数数组,将当前参数存入
                    char **newargv = hi_realloc(curargv,sizeof(char*)*(argc+1));
                    if (newargv == NULL) goto memory_err;
                    curargv = newargv;
                    curargv[argc++] = curarg; // 每个参数独立存储,不拼接
                    totlen += bulklen(hi_sdslen(curarg));

                    // 重置临时参数存储,准备解析下一个参数
                    curarg = hi_sdsempty();
                    if (curarg == NULL) goto memory_err;
                    touched = 0;
                }
            } else {
                // 非空格字符:拼接到当前参数(恶意字符仅作为普通字符存储)
                hisds newarg = hi_sdscatlen(curarg,c,1);
                if (newarg == NULL) goto memory_err;
                curarg = newarg;
                touched = 1;
            }
        } else {
            /* ========== 核心步骤2:处理占位符(%s/%b等),安全填充参数 ========== */
            char *arg;
            size_t size;
            hisds newarg = curarg;

            switch(c[1]) {
            case 's': // 字符串参数:安全获取参数,拼接到当前参数(不解析恶意命令)
                arg = va_arg(ap,char*);
                size = strlen(arg);
                if (size > 0)
                    newarg = hi_sdscatlen(curarg,arg,size);
                break;
            case 'b': // 二进制参数:按长度读取,避免解析\r\n等协议分隔符
                arg = va_arg(ap,char*);
                size = va_arg(ap,size_t);
                if (size > 0)
                    newarg = hi_sdscatlen(curarg,arg,size);
                break;
            case '%': // 转义%:仅作为普通字符存储,不解析为占位符
                newarg = hi_sdscat(curarg,"%");
                break;
            }

            if (newarg == NULL) goto memory_err;
            curarg = newarg;
            touched = 1;
            c++; // 跳过占位符的第二个字符(如%s的s)
        }
        c++;
    }

    /* ========== 核心步骤3:收尾最后一个参数,存入参数数组 ========== */
    if (touched) {
        char **newargv = hi_realloc(curargv,sizeof(char*)*(argc+1));
        if (newargv == NULL) goto memory_err;
        curargv = newargv;
        curargv[argc++] = curarg; // 最后一个参数存入数组
        totlen += bulklen(hi_sdslen(curarg));
    } else {
        hi_sdsfree(curarg);
    }

    /* ========== 核心步骤4:封装为RESP协议(最终防注入保障) ========== */
    // 计算RESP协议头长度(*argc\r\n)
    totlen += 1+countDigits(argc)+2;
    char *cmd = hi_malloc(totlen+1);
    if (cmd == NULL) goto memory_err;

    // 1. 写入RESP协议头:*参数个数\r\n(标识这是一个参数数组)
    int pos = sprintf(cmd,"*%d\r\n",argc);
    // 2. 遍历参数数组,按RESP格式写入每个参数($长度\r\n值\r\n)
    for (int j = 0; j < argc; j++) {
        // 写入参数长度:$长度\r\n
        pos += sprintf(cmd+pos,"$%zu\r\n",hi_sdslen(curargv[j]));
        // 写入参数内容(含恶意字符的参数仅作为普通字符串)
        memcpy(cmd+pos,curargv[j],hi_sdslen(curargv[j]));
        pos += hi_sdslen(curargv[j]);
        hi_sdsfree(curargv[j]);
        // 写入参数结尾分隔符:\r\n(转义恶意的\r\n,避免解析为新命令)
        cmd[pos++] = '\r';
        cmd[pos++] = '\n';
    }
    cmd[pos] = '\0';

    /* ========== 资源释放与结果返回 ========== */
    hi_free(curargv);
    *target = cmd; // 最终的RESP协议命令(防注入后的安全格式)
    return totlen;

}

二.官方文档说明

1.概述

接着redis官方提供了对于RESP的详细介绍,包括服务器端收到消息会怎么解析,以及客户端以怎样的形式去发送redis的命令,下面是链接,可以查看

https://redis.io/docs/latest/develop/reference/protocol-spec/

文中介绍了该协议的基本概念:RESP 可以序列化不同的数据类型,包括整数、字符串和数组。 它还具有一种针对错误的特定类型。 客户端以字符串数组的形式向 Redis 服务器发送请求。 数组的内容是服务器应执行的命令及其参数。 服务器的回复类型是命令特定的。

RESP 是二进制安全的,使用前缀长度传输大批量数据,因此无需处理从一个进程传输到另一个进程的批量数据。

2.RESP协议描述

这是服务器对于接收到消息的接收处理,他说:数组中的第一个(有时也是第二个)批量字符串就是命令的名称,数组的后续元素是命令的参数。并且用\r\n分隔其各部分。

3.向Redis服务器发送命令

这里定义了客户端向服务器发送命令的格式

三.测试验证

1.字符串拼接注入测试

核心逻辑:直接拼接恶意命令; KEYS *;到 SET 字符串中,模拟注入漏洞,验证 Redis 会执行注入命令。

redis日志显示:在kamailio.cfg.template脚本中request_route中添加以下代码:

bash 复制代码
# 1. 定义恶意注入内容
$var(malicious) = "test_key; KEYS *;";    # 注入KEYS *(只读,无破坏性) 
# 2. 危险写法:字符串拼接SET命令(留注入漏洞) 
$var(danger_cmd) = "SET " + $var(malicious) + " 123";    # 拼接成:SET test_key; KEYS *; 123 
$var(res) = redis_cmd("db0", "$var(danger_cmd)", "test_danger");

redis日志显示:

可以看到他把这个命令拆分成了多个参数,接着在redis中直接执行这个命令发现报语法错误,所以注入的redis命令无法执行或生效,就是没有注入成功。

那如果把冒号改成空格,能否注入成功呢

bash 复制代码
# 1. 定义恶意注入内容
$var(malicious) = "test_key KEYS *";    # 注入KEYS *(只读,无破坏性) 
# 2. 危险写法:字符串拼接SET命令(留注入漏洞) 
$var(danger_cmd) = "SET " + $var(malicious) ;   
$var(res) = redis_cmd("db0", "$var(danger_cmd)", "test_danger");

可以看到命令仍然无法被拆分成正确的格式,并且在redis中执行该命令,也是语法错误,注入失败。

综上所述,对于redis_cmd来说,对于命令的发送和解析遵循底层的RESP协议,即使是拼接命令,redis收到命令之后,第一个参数当作命令的名称,后续输入的内容都是命令的参数,不会发生注入。

2.占位符参数化注入测试

核心逻辑:用%s占位符传递含恶意内容的参数,验证 Redis 仅把恶意内容当作 key 值,不执行注入命令。

在脚本中添加以下内容

bash 复制代码
# 1. 定义恶意内容(和场景1完全一样) 
$var(malicious) = "test_key KEYS *";
# 2. 安全写法:占位符参数化(最简单的SET key value) 
$var(res) = redis_cmd("db0", "SET %s %s", "$var(malicious)", "123","abc"); # 占位符传key和value

日志中图片显示KEYS 并没有作为一个单独命令,而是作为一个参数

查询keys的value,发现KEYS是添加到后续原本要设置的key后面,设置的value是123。

综上所述,如果是用占位符参数化防止注入,也是可以成功的,还是依赖于底层的RESP协议,redis收到命令之后,第一个参数当作命令的名称,后续输入的内容都是命令的参数,不会发生注入。

3.RESP协议二进制注入测试

核心逻辑:传递含\r\n(RESP 协议分隔符)的恶意二进制内容,验证 Redis 不解析为新命令,仅当作普通字符。

在脚本中添加以下内容:

bash 复制代码
# 1. 定义恶意内容(和场景1完全一样) 
$var(malicious_bin) = "hack_key\r\n$3\r\nSET\r\n$5\r\nhack_val\r\n$3\r\n123\r\n";
# 2. 安全写法:占位符传递二进制恶意内容(最简单的SET)
$var(res) = redis_cmd("db0", "SET %s %s", "$var(malicious_bin)", "456", "abc");

原本是要执行两次SET命令的,但是redis日志显示还是把后面的当作一整个参数。

可以发现设置了"hack_key\r\n3\\r\\nSET\\r\\n5\r\nhack_val\r\n$3\r\n123\r\n"整体的值为456

综上所述,由于RESP底层对于命令的发送和解析是依赖\r\n分隔,模拟这样的二进制内容,仍然会把命令名称后面的内容当作参数,无法注入成功。

四.结论

所以最后的结论就是,通过查看源码以及官方文档,还有进行不同场景下的实验测试,发现redis_cmd底层设计了参数处理逻辑和RESP协议封装,天然就具备防御命令注入的能力。

redis_cmd会将第一个元素识别为Redis命令名,后续的所有内容无论是含(;/\n)都会当作当前命令的参数,不会被解析为独立的命令。

RESP协议的封装,redis_cmd会将所有参数按协议规范封装(长度\\r\\n值\\r\\n),对于参数中的\\r\\n协议分隔符会自动转义为普通字符,构造含 \\r\\n3\r\nsET\r\n$5\r\nhack_val\r\n 的二进制恶意内容,最终仅作为 SET 命令的 key字符串存储,Redis 未解析出注入的 sET hack_val 123 命令。

相关推荐
科技小花9 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸9 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain9 小时前
linux个人心得22 (mysql)
数据库·mysql
阿里小阿希10 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神10 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员10 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java10 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿10 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb
不知名的老吴10 小时前
Redis的延迟瓶颈:TCP栈开销无法避免
数据库·redis·缓存
YOU OU10 小时前
三大范式和E-R图
数据库