漏洞信息
漏洞简介
- 漏洞名称:Redis缓冲区溢出漏洞
- 漏洞编号: CVE-2015-8080/CVE-2020-14147
- 漏洞类型:缓冲区溢出
- CVSS评分:【CVSS v3.0:7.7】
- 漏洞危害等级:高危
组件概述
Redis(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。它通常被称为数据结构服务器,因为值(value)可以是字符串(String), 哈希(Hash), 列表(list), 集合(sets) 和 有序集合(sorted sets)等类型。
漏洞概述
Redis 6.0.3之前的lua_struct.c中的getnum函数中存在整数溢出漏洞,这允许具有上下文相关权限的攻击者在Redis会话中运行Lua代码,从而导致拒绝服务(内存损坏和应用程序崩溃)。
漏洞利用条件
Redis部署在服务器端,开启6379端口;攻击者,通过构造RESP协议包,利用struct.pack方法用于转换指定为第一个参数的非常大的整数(2^31),从而能触发整数溢出,再导致缓冲区溢出。
漏洞影响
Redis 小于 6.0.3
漏洞修复
https://github.com/antirez/redis/commit/ef764dde1cca2f25d00686673d1bc89448819571
漏洞复现
应用协议
6379/RESP(Redis的序列化协议)
环境安装/搭建
Linux下安装Redis 6.0.0
首先,Redis对全版本进行了补丁修复,为了复现漏洞,将Redis 6.0.0源码改为未打CVE-2020-14147补丁之前的状态。
进入路径,找到lua_struct.c文件
1 | \redis-6.0.0\deps\lua\src、lua_struct.c |
用编辑器,注释以下98-99行代码:
1 | if (a > (INT_MAX / 10) || a * 10 > (INT_MAX - (**fmt - '0'))) |
使用以下命令下载,提取和编译Redis 6.0.0:
1 | $ wget https://download.redis.io/releases/redis-6.0.0.tar.gz |
PS:中间make会报错
在源码的README.md文件中看到解释
解决办法(针对2.2以上的版本)
清理上次编译残留文件,重新编译
1 | make distclean && make |
这个错误的本质是我们在开始执行make 时遇到了错误(大部分是由于gcc未安装),然后我们安装好了gcc 后,我们再执行make ,这时就出现了jemalloc/jemalloc.h: No such file or directory。这是因为上次的编译失败,有残留的文件,我们需要清理下,然后重新编译就可以了。
src
目录 中现在提供了已编译的二进制文件 。使用以下命令运行Redis服务:
1 | $ src/redis-server |
可以使用内置客户端与Redis进行交互:
1 | $ src/redis-cli |
Redis服务端,配置远程连接密码:
Redis客户端,远程连接:
安装Lua
Linux安装 Lua ,下载源码包并在终端解压编译即可
1 | curl -R -O http://www.lua.org/ftp/lua-5.3.0.tar.gz |
make报错,找不到readline/readline.h
原因是缺少 libreadline-dev
依赖包,安装依赖包:
1 | apt-get install libreadline-dev |
再次编译安装后,成功:
漏洞复现
利用Python脚本poc.py来重现该漏洞。
1 | python poc.py <host> [<port>] |
初次连接,发送poc时,提示redis运行保护模式,只允许本地访问,有三种关闭方式,此处选择第一种关闭方式:
根据提示关闭,保护模式,并且取消密码设置。
1 | CONFIG SET protected-mode no |
再次发送poc,无数据回显,拒绝服务攻击成功。
Redis服务端报段错误,说明漏洞触发。
漏洞分析
技术背景
Redis是轻量级的,非易失性键值数据存储。 它通过Redis序列化协议(RESP)提供对简单易变数据结构的访问,该协议是基于TCP的协议。 与大多数其他数据库一样,Redis遵循客户端—服务器模型。 客户端能够通过Redis命令在Redis服务器上创建,修改和检索记录。
例如,以下命令创建“ TEST”字符串记录并将其分配给“ 1234”键值,将此记录修改为“ TEST2”并分别检索记录:
1 | SET 1234 TEST |
有关Redis命令的完整列表,请参考 http://redis.io/commands
Redis客户端通过端口6379通过TCP使用Redis序列化协议(RESP)与服务器进行通信。可通过 http://redis.io/topics/protocol获得该协议详细说明。 RESP使用五种数据类型,这些数据类型由相应数据的第一个字节标识:
简单字符串以“ +”字符开头
错误以“-”字符开头
整数以“:”字符开头
批量字符串以“ $”字符开头
数组以“ *”字符开头
批量字符串以“ $”字符开头,后跟相应字符串的长度。 以下重点介绍如何将“ Sangfor”表示为大容量字符串:
1 | $7 CRLF |
其中CRLF表示新的行序列回车(CR),后跟换行(LF)。
RESP数组以“ *”字符开头,后跟数组中的元素数。 下面说明了一个由2个元素组成的大容量字符串数组:
1 | *2 CRLF |
所有Redis命令都通过RESP字符串数组发送到服务器。 例如,上述SET命令将以下形式发送:
1 | *3 CRLF |
Lua是Redis 支持的轻量级脚本语言。 Redis内置了Lua解释器。Lua在Redis中的使用方法,可参考https://www.redisgreen.net/blog/intro-to-lua-for-redis-programmers/
Redis客户端可通过EVAL命令使用此解释器。 Lua脚本允许用户管理和操纵Redis服务器上的记录。 例如,以下Lua脚本可用于执行上述SET命令:
1 | EVAL "redis.call('set','1234','TEST')" 0 |
执行此Lua脚本的另一种方法是使用SCRIPT LOAD和EVALSHA命令。 SCRIPT LOAD命令在服务器上缓存Lua脚本,并返回SHA1摘要。 EVALSHA命令可以与此SHA1摘要一起用作执行脚本的参数。 下面是一个示例:
1 | SCRIPT LOAD "redis.call('set','1234','TEST')" 0 |
Lua还提供了分别使用struct.pack和struct.unpack方法将Lua变量与C类型的Stucts相互转换的方法。 例如,以下内容可用于包装整数:
1 | struct.pack ("<I2", 10) |
- “I”指定整数是无符号的(“ i”用于有符号整数)
- “<”指定应以小端格式表示(“>”用于大端格式)
- “2”指定整数为2个字节长
- “10”指定要打包的整数的值
详细分析
漏洞利用过程
Redis中存在堆栈缓冲区溢出漏洞。 在攻击情况下,struct.pack方法用于转换指定为第一个参数的非常大的整数(2^31)。 lua_struct.c尝试通过调用getnum()将此字符串参数转换为整数。 这导致整数溢出,导致大小被错误地计算为负数,从而绕过边界检查。 然后,此负数通过optsize()强制转换为size_t类型的变量,这将其解释为大正数。 之后,putinteger()尝试将由这个大数字表示的字节数复制到32字节的缓冲区中,从而导致缓冲区溢出。
代码分析
以下代码段摘自lua_struct.c,Redis版本6.0.0。
分析lua_struct.c中b_pack()函数,size变量存放optsize()函数的返回值,optsize()函数将有符号的int类型转换为size_t(无符号的int类型)整数溢出发生字此函数内,之后size变量传入putinteger()函数,缓冲区溢出发生在此函数内。
先跟进optsize()函数,看如何处理opt变量和fmt二级指针(指向指针的指针),发现当opt变量为字符’i’或’I’时,将fmt二级指针传入
getnum()函数,将返回的值存入sz变量中,if语句判断sz变量是否大于MAXINTSIZE;当sz发生整数溢出时,值为负值且小于MAXINTSIZE(0x7fffffff),所以跳过此报错判断。
跟进getnum()函数,字符串转整型,缺少边界检查,当字符串表示的数大于MAXINTSIZE(0x7fffffff)时,直接返回负值。
跟进putinteger()函数,变量size是无符号的整数,经过int类型转换后,负值变为一个大于0x7fffffff的大数,buff数组范围为MAXINTSIZE(0x7fffffff),size变量范围大于MAXINTSIZE(0x7fffffff),故导致缓冲区溢出。
漏洞触发过程
struct.pack指定为第一个参数的非常大的整数(2^31),b_pack()———>optsize()———>getnum()导致整数溢出;返回的无符号变量size传入,putinteger()导致缓冲区溢出。
发送poc后,到达崩溃现场,ESP与EBP中间地址被覆盖:
Redis服务端尝试恢复内存数据失败后退出进程,达到拒绝服务目的。
补丁分析
官方已经发布补丁,在getnum()函数处,增加lua_State变量指针L,来存放错误信息,在98-99行增加代码:
1 | if (a > (INT_MAX / 10) || a * 10 > (INT_MAX - (**fmt - '0'))) |
对当前变量a的值做判断,若当前a大于INT_MAX/10或者当前a*10大于INT_MAX-当前位的值,则返回报错信息L,提示size值超过缓冲区的值。从根本上避免了整数溢出,也避免了后续因为有符号上溢,而导致的缓冲区溢出。
流量分析
攻击包分析
攻击包为RESP协议,Redis默认监听在6379,使用Redis命令,Redis命令详解请看 3.1技术背景。
1 | *3表示创建一个大小为3的数组 |
流量结构等同于在Redis客户端输入以下命令:
1 | EVAL "struct.pack('<4294967295', '1', '2', '3')" 0 |
之后无返回数据,则说明拒绝服务攻击成功。
正常包分析
Redis客户端发送RESP命令:
1 | EVAL "struct.pack('<I1', '1', '2', '3')" 0 |
正常包是符合RESP协议,会有回显。
漏洞检测和防御
漏洞检测
缓冲区溢出漏洞只能通过版本对比检测。
运行Redis-server后,控制台会打印版本信息,通过版本比对,若版本低于6.0.3,则有漏洞。
漏洞防御
匹配6379端口的流量,匹配字符串的RESP数组。 由于RESP数组始终以“ *”字符开头,对该字符进行字符串匹配。 检测数组的元素是否有EVAL或SCRIPT LOAD命令。
然后,检测调用struct.pack()方法的数组中匹配Lua脚本代码,并提取该方法的第一个参数。 必须确保此字符串参数的前两个字节包含以下任何字符:“ I”,“ i”,“ <”或““>”。 如果此值大于0x7FFFFFFF,则应将流量视为恶意流量,并且可能正在利用此漏洞进行攻击。
- 必须以区分大小写的方式对Lua脚本代码中的对象和方法名称进行字符串匹配。
- 必须以不区分大小写的方式执行RESP协议上的字符串匹配。
- 由于脚本语言的性质,使用各种脚本技术很容易混淆代码的真实攻击。
漏洞修复
https://github.com/antirez/redis/commit/ef764dde1cca2f25d00686673d1bc89448819571