漏洞信息
漏洞简介
- 漏洞名称:Redis EVAL Lua沙箱绕过漏洞
- 漏洞编号:CVE-2015-4335
- 漏洞类型:代码注入
- CVSS评分:【CVSS v2.0:10.0】【CVSS v3.0:】
- 漏洞危害等级:高危
组件概述
Redis(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSI C语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API。
它通常被称为数据结构服务器,因为值(value)可以是 字符串(String), 哈希(Hash), 列表(list), 集合(sets) 和 有序集合(sorted sets)等类型。
漏洞概述
Redis 2.8.1之前版本和3.0.2之前3.x版本中存在安全漏洞。远程攻击者可执行eval命令利用该漏洞执行任意Lua字节码。
漏洞影响
Redis:up to 2.8.20
Redis:3.0.0 up to 3.0.1
漏洞修复
https://github.com/redis/redis/commit/fdf9d455098f54f7666c702ae464e6ea21e25411
漏洞复现
应用协议
6379/RESP(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”指定要打包的整数的值
ZADD key score member [[score member] [score member] …]
可用版本: >= 1.2.0
时间复杂度: O(M*log(N)),
N
是有序集的基数,M
为成功添加的新成员的数量。
将一个或多个 member
元素及其 score
值加入到有序集 key
当中。
如果某个 member
已经是有序集的成员,那么更新这个 member
的 score
值,并通过重新插入这个 member
元素,来保证该 member
在正确的位置上。
score
值可以是整数值或双精度浮点数。
如果 key
不存在,则创建一个空的有序集并执行 ZADD 操作。
当 key
存在但不是有序集类型时,返回一个错误。
对有序集的更多介绍请参见 sorted set 。
Note:在 Redis 2.4 版本以前, ZADD 每次只能添加一个元素。
返回值
被成功添加的新成员的数量,不包括那些被更新的、已经存在的成员。
代码示例
1 | # 添加单个元素 |
LuaDec是lua 5.1的Lua反编译器,并且是lua 5.2和5.3的实验版。它基于Hisham Muhammad的luadec,其针对Zsolt Sz的lua 5.0.x和LuaDec51。Sztupak。
LuaDec是免费软件,并且使用与原始LuaDec相同的许可证。
编译中
1 | git clone https://github.com/viruscamp/luadec |
如果要为lua 5.2或5.3构建它,只需将上面的5.1替换为5.2或5.3。
还有vc2008的项目文件,已针对vc2008和vc2013进行了测试。
编译之前,请确保lua-5.1,lua-5.2或lua-5.3中的源正确。
用法
- 反编译lua二进制文件:
luadec abc.luac - 反编译lua源文件以进行测试和比较:
luadec abc.lua - 反汇编lua源代码或二进制
luadec -dis abc.lua - -pn打印嵌套函数结构,-fn可以使用
1 | luadec -pn test.lua |
- -f仅反编译特定的嵌套函数
luadec -f 0_1 test.lua - -ns不处理子函数
luadec -ns -f 0_1 test.lua - -fc对每个函数
luadec -fc test.lua进行逐条指令比较
:输出
-函数检查通过0-
函数检查失败0_0:无法编译
-函数检查失败0_1:不同的代码大小;sizecode组织:66;反编译:67;相同:47;
还有更多选项,通常用于调试目的,或用于内置本地猜测器猜测错误的情况。使用-h获取可用参数的完整列表
lua的字节码
lua源码在执行前,会被编译为字节码,字节码能加快程序的加载,保存lua源码被意外的修复。lua的字节码只在具有相同的字长和字节顺序的机器上能够移植。
luac编译器能将lua源码编译为字节码二进制文件,其命令如:
1 | luac a.lua |
luac默认的输出文件为luac.out,可以通过 -o 选项来指定输出文件。
1 | luac -o a.out a.lua |
当Lua发布新版时,luac生成的二进制文件的内部格式可能改变。
字节码文件头
lua5.1字节码文件头的长度为12字节,Win7 64位,VS下编译为Win32应用如下:
1 | 1b4c 7561 5100 0104 0404 0800 |
其中第1-4字节为:”\033Lua”;第5字节标识lua的版本号,lua5.1为 0x51;第6字节为官方中保留,lua5.1中为 0x0;
第7字节标识字节序,little-endian为0x01,big-endian为0x00;
第8字节为sizeof(int);第9字节为sizeof(size_t);第10字节为sizeof(Instruction),Instruction为lua内的指令类型,在32位以上的机器上为unsigned int;第11字节为sizeof(lua_Number),lua_Number即为double;
第12字节是判断lua_Number类型起否有效,一般为 0x00;
lua5.2字节码文件头的长度为18字节,在我的环境里(Win7 64位,VS下编译为Win32应用)如下:
1 | 1b4c 7561 5200 0104 0404 0800 1993 0d0a 1a0a |
其中第1-12字节与lua5.1意义相同,第5字节在lua5.2中为 0x52;
第13-18字节是为了捕获字节码的转换错误而设置的,其值为 “\x19\x93\r\n\x1a\n”;
PS:lua在判断字节序时使用的方法如下:
1 | 1 void luaU_header (char* h) |
在little-endian时,*(char*)&x值为0x01;big-endian时,*(char*)&x值为 0x00;
字节码文件正文
lua5.1 在文件头之后,就是正头,它由一个个函数组成,其中第一个函数包含由文件内全部内容,引全局函数名为”@”+文件件名(包含”.lua”后缀),在此文件中定义的函数都会在全局函数中以常量字符串保存;
每个函数的内容缓存如下:
1 | 源文件名的长度(包括'\0'),为sizeof(size_t)个字节长,只有全局函数有源文件名,其它内部函数其长度填0; |
文件binc.lua的内容如下:
1 | 1 local i = 6; |
其正文的字节码(lua5.1编译,不包括调试信息,前面的空白外为文件头):
1 | 0a00 0000 |
lua5.2 的正文部分与lua5.1存在差别。其没有源文件名的说明,别对于upvalue的处理也不一样,lua5.2中upvalue在常量后面定义,由upvalue的数目后加上分upvalue的定义组成。
string.gsub()
- 原型:string.gsub (s, pattern, repl [,m])
- 解释:这个函数会返回一个替换后的副本,原串中所有的符合参数
pattern
的子串都将被参数repl
所指定的字符串所替换,如果指定了参数m
,那么只替换查找过程的前m
个匹配的子串,参数repl
可以是一个字符串、表、或者是函数,并且函数可以将匹配的次数作为函数的第二个参数返回,接下来看看参数repl
的含义: - 如果参数
repl
是一个常规字符串,成功匹配的子串会被repl
直接替换,如果参数repl
中包含转移字符%
,那么可以采用%n
的形式替换,当%n
中的n
取值1-9时,表示一次匹配中的第n个子串,当其中的n
为0时,表示这次匹配的整个子串,%%
表示一个单独的%
。 - 如果参数
repl
是一个表,那么每次匹配中的第一个子串将会作为整个表的键,取table[匹配子串]来替换所匹配出来的子串,当匹配不成功时,函数会使用整个字符串来作为table的键值。 - 如果参数
repl
是一个函数,那么每一次匹配的子串都将作为整个函数的参数,取function(匹配子串)来替换所匹配出来的子串,当匹配不成功时,函数会使用整个字符串来作为函数的参数。如果函数的返回值是一个数字或者是字符串,那么会直接拿来替换,如果它返回false
或者nil
,替换动作将不会发生,如果返回其他的值将会报错。
Usage
- 首先新建一个文件将文件命名为gsubtest.lua然后编写如下代码:
1 | -- 常规替换 |
- 有一点需要注意的是,使用函数作为替换结果时,函数只能返回数字、字符串、
false
和nil
。
string.dump()
- 原型:string.dump (function)
- 解释:返回一个包含所给函数二进制描述的字符串,以至于在此之后可以使用函数
loadstring()
利用所得到的字符串来返回一个函数拷贝,需要注意的是函数只能是Lua函数并且没有upvalues(外部局部变量)。
Usage
- 首先新建一个文件将文件命名为dumptest.lua如下代码:
1 | --自定义一个函数 |
- 看了这个函数是不是有种非常神奇的感觉,原来传说中的序列化可以离我们这么近。
- 在调用了函数
string.dump()
之后函数custom_func()
被转化成字符串保存在文件中,可以在使用时再取出来。 - 这个函数一般写逻辑代码应该用不到,更多的是做框架的时候用的功能,进过序列化的函数可以通过网络传送、转化、再使用。
loadstring()
- loadstring(string [,chunkname])
- 解释:函数会从所给的字符串中来加载程序块并运行,常使用这种构造式来调用
assert(loadstring(s))()
,如果省略参数chunkname
,那么它默认为所给的字符串。
usage
- 首先我们新建一个文件将文件命名为loadstring.lua然后编写代码如下:
1 | -- 简单测试 |
- 由结果一可知,函数
loadstring()
与函数loadfile()
一样,都是返回一个函数。 - 由结果二可知,返回函数的内容就是字符串参数
string
的内容,执行返回的函数时,字符串中的代码就被执行了。 - 结果三展示了这个函数的一般使用方法。
- 结果四展示了如何通过字符串生成一个全局函数,但是无法生成局部函数,并且生成的函数
func_test()
在调用完函数func()
之后才被创建出来 - 最后一个例子展示了参数
chunkname
的作用,就是在错误的提示信息中起到提示作用。
lua数据结构–闭包
闭包主要由以下2个元素组成:
函数原型:上图意在表明是一段可执行代码。在Lua中可以是lua_CFunction,也可以是lua自身的虚拟机指令。
上下文环境:在Lua里主要是Upvalues和env,下面会有说明Upvalues和env。 在Lua里,我们也从闭包开始,逐步看出整个结构模型,下面是Closure的数据结构:(lobject.h 291-312)
Lua的闭包分成2类,一类是CClosure,即luaC函数的闭包。另一类是LClosure,是Lua里面原生的函数的闭包。
分析一下两类Closure相同部分ClosureHeader:
CommonHeader:和与TValue中的GCHeader能对应起来的部分
isC:是否CClosure
nupvalues:外部对象个数
gclist:用于GC销毁
env:函数的运行环境
对于CClosure数据结构:
lua_CFunction f:函数指针,指向自定义的C函数
TValue upvalue[1]:C的闭包中,用户绑定的任意数量个upvalue
对于LClosure数据结构:
Proto *p:Lua的函数原型,在下面会有详细说明
UpVal *upvals:Lua的函数upvalue,这里的类型是UpVal,这里之所以不直接用TValue是因为具体实现需要一些额外数据。
UpVal的实现
什么是UpVal?先来看看代码:
分析一下上面这段代码,最终testB的值显然是3+5+10=18。当调用testA(5)的时候,其实是在调用FuncB(5),但是这个FuncB知道a = 3,这个是由FuncA调用时,记录到FuncB的外部变量,我们把a和c称为FuncB的upvalue。
下面描述一下Lua中的原生函数的函数原型,即Proto数据结构(lobject.h 231-253):
CommonHeader:Proto也是需要回收的对象,也会有与GCHeader对应的CommonHeader
TValue* k:函数使用的常量数组,比如local d = 10,则会有一个10的数值常量
Instruction *code:虚拟机指令码数组
Proto **p:函数里定义的函数的函数原型,比如funcA里定义了funcB,在funcA的5. Proto中,这个指针的[0]会指向funcB的Proto
int *lineinfo:主要用于调试,每个操作码所对应的行号
LocVar *locvars:主要用于调试,记录每个本地变量的名称和作用范围
TString **upvalues:一来用于调试,二来用于给API使用,记录所有upvalues的名称
TString *source:用于调试,函数来源,如c:\t1.lua@ main
sizeupvalues: upvalues名称的数组长度
sizek:常量数组长度
sizecode:code数组长度
sizelineinfo:lineinfo数组长度
sizep:p数组长度
sizelocvars:locvars数组长度
linedefined:函数定义起始行号,即function语句行号
lastlinedefined:函数结束行号,即end语句行号
gclist:用于回收
nups:upvalue的个数,其实在Closure里也有nupvalues,这里我也不太清楚为什么要弄两个,nups是语法分析时会生成的,而nupvalues是动态计算的。
numparams:参数个数
is_vararg:是否参数是”…”(可变参数传递)
maxstacksize:函数所使用的stacksize
Proto的所有参数都是在语法分析和中间代码生成时获取的,相当于编译出来的汇编码一样是不会变的,动态性是在Closure中体现的。
详细分析
漏洞利用过程
利用到的漏洞分别为OP_FORPREP/OP_FORLOOP、OP_CLOSURE中的类型混淆,LUA提供了string.dump将一个lua函数dump为LUA字节码,同时loadstring函数加载字节码为LUA函数,通过操作LUA原始字节码可以使得LUA解释器进入特殊状态,甚至导致BUG发生。
具备任意地址读/写能力后是一定可以做代码执行的,目前想到如下两种方式。
1) 覆写CClosure->f
在lua中可以使用coroutine.wrap创建C函数闭包对象CClosure,其结构如下:
CClosure->f指向函数指针,调用其对应的源码为deps/lua/src/ldo.c 307-326:
2) 覆写got
Linux PWN常规思路,通过DynELF解析Binary,进一步解析libc,获取system地址并覆写至fputs.got;在lua中调用print(“id”)即可执行命令。
代码分析
利用到的漏洞分别为OP_FORPREP/OP_FORLOOP、OP_CLOSURE中的类型混淆,LUA提供了string.dump将一个lua函数dump为LUA字节码,同时loadstring函数加载字节码为LUA函数,通过操作LUA原始字节码可以使得LUA解释器进入特殊状态,甚至导致BUG发生。
这里以Redis 3.0.0版本进行分析。
1 | #!cpp |
LUA字节码固定长度32bits,4字节,定义如下:
主要由op操作码、R(A)、R(B)、R(C)、R(Bx)、R(sBx)组成。A、B、C对应于LUA寄存器索引。
asnum函数可以将任意LUA对象转换为数字。(注:LUA5.1 64bitLinux环境)gsub函数将字节码\96%z%z\128
替换为\22\0\0\128
。
1) OP_FORPREP/OP_FORLOOP
lua中对for循环生成的字节码,利用luadec反编译工具查看.out文件的字节码如下:
可以看到for循环是由FORPREP、FORLOOP两条指令组合而来,对应的源码是deps/lua/src/lvm.c 的luaV_execute函数(line 654-680):
在OP_FORPREP中,lua对参数进行类型检查,判断是否为number类型,若不是则触发错误;然而在OP_FORLOOP中,因已做过类型检查,便假定参数为number类型,并对其执行idx = idx + step操作,这导致任意类型到number类型的混淆。
如下修改字节码中的FORPREP指令\96%z%z\128
为JMP指令\22\0\0\128
,gsub函数将字节码\96%z%z\128
替换为\22\0\0\128
,如下。
正常情况下lua在forprep指令会检查参数是否为数字类型,并执行初始化,但是由于执行gsub函数后,forprep字节码被替换为JMP to 5,直接跳过OP_FORPREP中的类型检查,直接进入OP_FORLOOP (5):
forloop指令直接将循环参数转换为lua_Number(double)类型,(因为正常情况下forprep已经检查过类型了),然后执行加法(+ 0),执行dojump return x;返回lua_Number。LUA使用TValue表示通用数据对象,格式如下:
Value(64bit) | tt(32bit) | padd(32bit) |
---|---|---|
n | LUA_TNUMBER |
|
GCObject *gc; -> TString* |
LUA_TSTRING |
|
GCObject *gc; -> Closure* |
LUA_TFUNCTION |
2) OP_CLOSURE
LUA使用CLOSURE A Bx指令创建函数的一个实例(或闭包)。Bx是要实例化的函数在函数原型表中的函数编号。例如:closure 2 0 ,创建0号函数对象,结果保存到2号寄存器。对CLOSURE指令的处理位于deps/lua/src/lvm.c 的luaV_execute函数(line 723-742):
line 731-737是对闭包的处理,具体为在CLOSURE指令后后生成对应的MOVE指令,MOVE指令的第二个参数为闭包变量引用。正常情况下引用只能指向当前栈桢中的局部变量,但通过修改字节码,可以将其指向至任意位置。
如上,通过修改”(\100%z%z%z)….”(MOVE 0 0)为”%1\0\0\0\1”(MOVE 0 2),将middle函数中的magic引用指向middle函数自身(R2),所以输出的结果为middle函数。
对函数调用的处理位于deps/lua/src/lvm.c line 586-606:
对函数返回的处理位于deps/lua/src/lvm.c line 382-390:
line 385将L->ci->func(当前函数指针)转换为Closure指针,由上文可知,通过修改字节码可以将闭包变量引用指向当前函数指针,导致任意类型到Closure类型的混淆。
基于此,结合number类型混淆,可以做任意地址读/写:
结尾处修改字节码,将middle/inner函数中的magic引用指向middle函数;inner函数中将magic赋值为字符串,这使得middle函数中的当前函数指针将被混淆为该字符串,函数返回; middle函数中读取闭包变量magic,读取闭包变量对应的源码为deps/lua/src/lvm.c line 427-431:
其实际上是去当前函数指针的upvals字段中获取相应引用,而当前函数指针已被混淆为字符串,对应的upvals字段可控。
TString类型与Closure类型的结构如下:
变量upval为字符串,as_double(upval)获取其TString指针,偏移24获取到upval->str地址,也就是说cl->p、cl->upvals[0]都指向输入的字符串”commonhead16bits” .. p32(lo) .. p32(hi)。
UpVal结构如下:
所以cl->upvals[0]->v指向构造的指针p32(lo) .. p32(hi),也即为addr。
以上,便将任意地址的前8字节读取出来,写操作也是同理,只需要在middle函数中对magic赋值,需注意的是写操作实际会写入8字节数值及4字节tt类型:
deps/lua/src/lvm.c line 451-456:
deps/lua/src/lobject.h line 161-164:
触发poc.lua
1 | #!cpp |
补丁分析
漏洞是因为加载字节码导致的,Redis中的修复,直接禁止字节码加载:
流量分析
直接eval命令发lua字节码脚本
参考资料
- http://redis.io/commands/ZADD
- http://benmmurphy.github.io/blog/2015/06/04/redis-eval-lua-sandbox-escape/
- https://github.com/redis/redis/commit/fdf9d455098f54f7666c702ae464e6ea21e25411
- https://github.com/viruscamp/luadec
- https://juejin.cn/post/6844903572673396750
- https://gist.github.com/corsix/6575486
- https://www.bbsmax.com/A/mo5kZWW2Jw/