Bind_shell
哈希算法设计
1.哈希算法不能发生碰撞,或者说存在发生碰撞的可能,但是可以肯定的是,首先匹配的就是目标函数.原则上不设计处理碰撞的算法
2.函数名的摘要值尽可能短
3.哈希算法自己的篇幅尽量短
4.哈希后的摘要值可以当作"准nop指令",即在数值上相当于某些机器码,但是这些机器码的执行不会对shellcode产生影响.如此可以省去跳转指令
书上采取的哈希算法是
1 | hash_loop: |
dl初始值为0,
将库函数名逐个字节与0x71异或然后用dl减去该值累计哈希值,
最终的哈希值放在dl中,只需要1个字节
用这个哈希算法得到的各个函数的哈希值:
函数名 | 哈希值 | 准nop指令 |
---|---|---|
LoadLibraryA | 0x59 | pop ecx |
CreateProcessA | 0x91 | |
ExitProcess | 0xc9 | |
WSAStartup | 0xd3 | or ecx,0x203062d3 |
WSASocketA | 0x62 | |
bind | 0x30 | |
listen | 0x20 | |
accept | 0x41 | ecx |
然后紧跟着cmd也写上
Ascii字符 | Ascii值 | 准nop指令 |
---|---|---|
C | 0x43 | inc ebx |
M | 0x4d | dec dbp |
d | 0x64 | FS: |
准nop指令全都是不疼不痒的指令
因此bind-shell一开始是这样写的
1 | ; start of shellcode |
_emit相当于db,作用是定义字节数据
这些数据会被当做指令执行下来,但是影响不大
用ida反汇编观察这片区域
1 | 59 pop ecx |
只要是不影响程序计数器eip的指令,比如跳转和调用,都可以作为准nop指令
后面的指令从windows11上调试会发生错误,需要在windowsXP上调
解析符号
用三层循环解析符号,最外圈遍历我们要找的符号,中圈遍历库函数,内圈计算一个库函数的哈希值然后和我们的哈希值进行比较
用伪代码表示为
1 | while(遍历我们给出的符号哈希表){ |
外圈循环初始化
使用装载器执行bindshell时,bindshell之前的指令是
1 | lea eax,bindshell |
eax的初始值为bindshell的基地址,bindshell在栈中,因此eax的初始值是一个栈中地址,这对于理解后面的cdq指令是有作用的
设置函数地址存放空间
1 | ; start of proper code |
cdq指令的作用是eax带符号拓展到edx:eax,又bindshell一开始时,eax寄存器中是一个用户栈地址,显然这个值是一个小于0x80000000的数,那么eax的最高位必然是0,带符号拓展到edx全是0,这就用最短的指令使得edx置零了
xchg将eax和esi寄存器中的值进行交换,交换前esi=0,eax=address(shellcode)
交换后eax=0,esi=address(shellcode),又shellcode最开始就是_emit 0x59 ; LoadLibraryA ; pop ecx
即LoadLibraryA函数的摘要值,因此相当于设置好了源操作数
然后esi-0x18这个地址放到edi上,距离esi共24字节,放到执行方向的反方向,意思是不要干扰shellcode的执行
奇怪的是,我们需要解析8个符号,为什么只给了24字节6个符号的地址空间呢?
这是因为后两个符号将会覆盖一开始给他设定的摘要的位置
获取kernel32.dll基地址
1 |
|
然后开始定位kernel32.dll的基地址
fs段选择子指向GDT中当前程序的线程环境块描述符,
用fs段超越寻址,fs:[edx+0x30],此时edx=0,实际上相当于fs:[0x30],也就是TEB表的0x30位置,即PEB的指针.
寄存器 | 值意义 |
---|---|
ebx | PEB基地址 |
ecx | initialisation order表第二项,kernel32.dll相关项 |
ebp | kernel32.dll的基地址 |
抬栈申请空间
1 |
|
edx始终是0,dh=0x03则edx=0x300
然后抬栈0x300字节
先前esp=0x12FF38,之后esp=0x12FC38
"ws2_32"字符串压栈
1 |
|
这里有三个压栈,前两个是压入"ws2_32",后面这个是保存当时的esp位置
外圈循环一次开始
1 | find_lib_functions: |
汇编语言中不会平白无故地设置标号的,都是为了方便跳转,这里"find_lib_functions"标号意味着循环开始了
lodsb相当于
1 | mov al,[esi] |
然后al和0xd3比较的意思是,是否是要解析WSAStartup这个函数,如果是,则说明要到ws2_32库中解析函数了,而刚才一直都是在kernel32.dll中解析函数,因此此时需要更换库基址,又已经从kernel32.dll解析了LoadLibraryA,因此可以直接调用该windowsAPI寻找ws2_32.dll的基地址
如果al=0xd3就顺序执行,不跳转,调用LoadLibraryA更换库函数地址,然后再执行find_functions,否则直接跳转find_functions
中圈循环初始化
实际上外圈循环一次,中圈就初始化一次,两个可以看成一块
1 | find_functions: |
首先将所有寄存器压栈,即使有的寄存器不需要压栈保存,也要使用这条指令,因为它短
假设当前仍然是在kernel32.dll中解析函数,这意味着find_lib_functions中的push edi不会执行,那么此时的堆栈状态是
ebp指向的是kernel32.dll或者ws2_32.dll的基地址,库基地址偏移0x3c的地方是库的PE头,
PE头偏移0x78的地方是导出函数地址(RVA)表的指针,这个地址交给ecx保管
ecx加上ebp的作用是获得导出函数地址表的绝对虚拟地址(刚才ecx中是相对于库基址的相对虚拟地址)
ecx+0x20处是导出函数名表的指针,解引用后将导出函数名表的相对基地址交给ebx保管
ebx再加上ebp就得到了导出函数名表的绝对虚拟地址
edi置零,将会作为中圈循环变量
寄存器 | 值意义 |
---|---|
eax | RVA(PE头) |
ebx | VA(导出函数名表) |
ecx | VA(导出函数地址表) |
edi | 中圈循环变量,作为导出函数表的下标 |
中圈循环一次开始
1 | next_function_loop: |
edi每次自增1,然后[ebx+edi*4]基址变址寻址,ebx是导出函数名表的基地址,ebx+edi*4
就相当于下标访问数组name[edi],
这个导出函数名表的每一项都是字符串指针,解引用后取得一个函数名字符串的相对基地址,放到esi,然后esi+ebp得到该函数名字符串的绝对基地址.
cdq用eax带符号拓展将edx置零,因为edx马上就要存放该导出函数名的哈希值了
内圈循环hash_loop
1 | hash_loop: |
内圈循环就是计算esi开始的导出函数名的哈希值
lodsb相当于
1 | mov al,[esi] |
每次取该函数名的一个字节放到al上,和0x71按位异或之后用dl减去该异或值,dl负责累计哈希值
如果al与0x71异或得到0x71说明al本来就是00,说明该字符串遍历到头'\0'了,此时算是计算完了本函数名的哈希值
中圈循环一次判断
1 | cmp dl, [esp + 0x1c] ; compare to the requested hash (saved on stack from pushad) |
al中存放刚计算出哈希值,esp+0x1c指向的是我们正在给他解析地址的函数摘要
al与[esp+0x1c]一比划,如果相等说明该找到了目标函数,跳出中圈循环
如果不相等说明当前库函数名不是我们想要解析的,需要中圈遍历下一个函数名,因此jnz next_function_loop
外圈循环一次结束,回写目标函数绝对地址
此时寄存器中值的意义来自 中圈循环初始化
ebx | VA(导出函数名表) |
---|---|
ecx | VA(导出函数地址表) |
1 | mov ebx, [ecx + 0x24] ; ebx = relative offset of ordinals table |
ecx+0x24指向序号表的RVA,解引用后将序号表RVA放到ebx上然后加上ebp中的库基址就得到了VA(序号表)
用函数名表的下标查序号表,得到的值作为下标查地址表就得到了函数地址,
在回写之前首先退栈还原edi,这是因为在中圈循环初始化时所有寄存器都压栈保存了,后来无法保证edi是否被修改过,因此这里采用堆栈上保存的值还原edi,由于edi是最后一个入栈的寄存器,此时位于栈顶,直接pop就可以还原了,在图上看就是
然后使用stosd,相当于
1 | mov [edi],eax |
这就将函数地址回写到了预留的函数地址空间中了
然后再将edi压栈是为了凑齐popad的结构,给edi占位防止错位弹出
最后popad将所有压栈保存的寄存器还原,此时的堆栈状态为
所有寄存器的意义为
外圈循环判断是否全部解析完毕
1 | cmp esi, edi ; loop until we reach end of last hash |
一开始给8个符号的地址只预留了24字节的空间,能够容纳6个符号,剩下两个符号需要覆盖摘要值
当edi追上esi的时候意味着覆盖完成,8个符号已经都解析了,此时外圈循环也结束了
建立Socket通信
1 | pop esi ; saved location of first winsock function |
在外圈循环一次的时候曾经区别处理过ws2_32.dll的函数有一个push edi压栈,意思是压栈保存第一条ws2_32.dll函数的地址
1
2
3
4
5
6
7
8
9
10 find_lib_functions:
lodsb ; load next hash into al and increment esi
cmp al, 0xd3 ; hash of WSAStartup - trigger
; LoadLibrary("ws2_32")
jne find_functions
xchg eax, ebp ; save current hash
call [edi - 0xc] ; LoadLibraryA
xchg eax, ebp ; restore current hash, and update ebp
; with base address of ws2_32.dll
push edi ; save location of addr of first winsock function
在执行本条指令之前的堆栈状态
pop esi之后,esi就指向了第一条ws2_32的函数在预留空间中的地址,也就是WSAStartup的预留地址
调用WSAStartUp
该API函数作用是为进程使用Winsock模块初始化,必须先调用该函数才可以激活Winsock模块中的其他函数,其接口为
1
2
3
4
5
6 int WSAStartup(
WORD wVersionRequired,//可以使用的windows 套接字规范的最高版本
[out] LPWSADATA lpWSAData//返回值,指向WSADATA结构体的指针
);其中32位机器上,WSADATA长这样
1
2
3
4
5
6
7
8
9
10 typedef struct WSAData {
WORD wVersion;//我们要使用的版本
WORD wHighVersion;//系统能提供给我们的版本
char szDescription[WSADESCRIPTION_LEN+1];
char szSystemStatus[WSASYS_STATUS_LEN+1];//当前库的秒数信息,2.0是第2版的意思
unsigned short iMaxSockets;//返回可用的socket数量,2版本只有就没用了
unsigned short iMaxUdpDg;//UDP数据报信息大小,2版本只有就没用了
char FAR * lpVendorInfo;//供应商特定的信息,2版本只有就没用了
} WSADATA;共11个字节
1 | ; initialize winsock |
首先esp压栈作为第二个参数的地址,返回的WSADATA结构体将会写到此时的栈顶
0x02压栈作为第一个参数,即套接字版本号
esi恰好就指向WSAStartUp的预留地址,lodsd相当于
1 | mov eax,[esi] |
就解引用将函数的绝对地址放到eax上了
然后call eax调用API,返回值写到所有参数之前的栈顶上,如果函数调用成功则eax=0
给"CMd"画上句号
趁着eax刚从WSAStartUp返回来,值为0,将0放到"CMd"字符串后面
1 | ; null-terminate "cmd" |
调用WSASocketA
WSASockA创建一个绑定到特殊服务提供者的套接字
其函数接口为
1
2
3
4
5
6
7
8 SOCKET WSAAPI WSASocketA(
[in] int af,//网络层协议,AF_INET(2)
[in] int type,//传输层类型SOCK_STREAM(1)
[in] int protocol,//传输层协议,TCP(6),UDP(17)
[in] LPWSAPROTOCOL_INFOA lpProtocolInfo,//WSAPROTOCOL_INFO结构体指针,用于存储信息
[in] GROUP g,//套接字组ID
[in] DWORD dwFlags//套接字属性标志
);如果调用成功,返回该套接字的描述符
清空栈帧,相当于填充NULL作为参数
1 | ; clear some stack to use as NULL parameters |
执行之前栈帧的状态
执行之后
1 |
|
调用WSASocketA之前,栈帧的状态为
函数调用返回后,eax中存放的是套接字描述符,交换到ebp中保存起来
bind,listen,accept循环
初始化bind参数
bind函数用来绑定套接字
其接口为
1
2
3
4
5 int bind(
[in] SOCKET s,//套接字描述符
const sockaddr *name,//sockaddr结构体指针,该结构体用于指定ipv4地址还有端口号
[in] int namelen//name指向的结构体的大小
);只要是
namelen>sizeof(*name)
就可以,因此namelen可以往大了设置为任意大小sockaddr用来指定ip协议类型,ip地址还有端口号
1
2
3
4 struct sockaddr {
unsigned short sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};相当于sockaddr_in结构体,二者在大小上是相同的
1
2
3
4
5
6 struct sockaddr_in {
short int sin_family; /* Address family */
unsigned short int sin_port; /* Port number */
struct in_addr sin_addr; /* Internet address */
unsigned char sin_zero[8]; /* Same size as struct sockaddr */
};
1 | ; push bind parameters |
0x0a1a0002这个值直接压栈作为namelen,足够大了
0x0a1a0002,最低两个字节0x0002用来填sockaddr_in.sin_family
然后用0x0a1a=6666填sockaddr_in.sin_port
将namelen的栈中地址压栈,因为其上的数也可以作为sock_addr结构体
到此还有一个参数没有压栈,即套接字描述符,它保存在ebp中
三联循环,复用循环代码
1 |
|
首先调用bind
ebp存放的是套接字描述符,压栈
然后lodsd将bind函数地址搬到eax上,然后函数edi函数指针后移4个字节,指向listen了
eax中是bind的地址,call eax调用bind了
如果调用成功则返回eax=0,又恰好bind,listen成功的返回值eax=0,accept成功的返回值非零,因此可以根据eax值判断该函数是不是accept
如果返回0则不是,说明三联还没有完成,重新call_loop
然后调用listen
listen函数,将套接字设置为监听状态
其接口为
1
2
3
4 int WSAAPI listen(
[in] SOCKET s,//套接字描述符
[in] int backlog//挂起连接队列的最大长度,不疼不痒
);
这里我们只对第一个参数,套接字描述符,感兴趣,backlog爱是啥是啥,反正不是0就行
于是在第二次循环一开始又将ebp压入作为第一个参数,历史上的堆栈,爱谁谁作为第二个参数
lodsd 又将listen地址放到eax上然后edi指向下一个函数accept,
然后就调用了listen,返回eax=0
最后调用accept
accept用于接收对套接字的连接
调用成功返回新的套接字描述符
拱手让shell
到此套接字通信就建立起来了,下面要创建一个cmd进程然后绑到套接字上
CreateProcess用于创建一个新进程并创建其主线程
1
2
3
4
5
6
7
8
9
10
11
12 BOOL CreateProcess(
LPCTSTR lpApplicationName, // 应用程序名称
LPTSTR lpCommandLine, // 命令行字符串 //这里放"CMd"
LPSECURITY_ATTRIBUTES lpProcessAttributes, // 进程的安全属性
LPSECURITY_ATTRIBUTES lpThreadAttributes, // 线程的安全属性
BOOL bInheritHandles, // 是否继承父进程的属性
DWORD dwCreationFlags, // 创建标志
LPVOID lpEnvironment, // 指向新的环境块的指针
LPCTSTR lpCurrentDirectory, // 指向当前目录名的指针
LPSTARTUPINFO lpStartupInfo, // 传递给新进程的信息
LPPROCESS_INFORMATION lpProcessInformation // 新进程返回的信息
);其中lpStartupInfo用于和套接字绑定,重要
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 typedef struct _STARTUPINFO
{
DWORD cb; //包含STARTUPINFO结构中的字节数.如果Microsoft将来扩展该结构,它可用作版本控制手段.
//应用程序必须将cb初始化为sizeof(STARTUPINFO)
PSTR lpReserved; //保留。必须初始化为N U L L
PSTR lpDesktop; //用于标识启动应用程序所在的桌面的名字。如果该桌面存在,新进程便与指定的桌面相关联。
//如果桌面不存在,便创建一个带有默认属性的桌面,并使用为新进程指定的名字。
//如果lpDesktop是NULL(这是最常见的情况),那么该进程将与当前桌面相关联
PSTR lpTitle; //用于设定控制台窗口的名称。如果l p Ti t l e 是N U L L ,则可执行文件的名字将用作窗口名
DWORD dwX; //用于设定应用程序窗口在屏幕上应该放置的位置的x 和y 坐标(以像素为单位)。
DWORD dwY; //只有当子进程用CW_USEDEFAULT作为CreateWindow的x参数来创建它的第一个重叠窗口时,
//才使用这两个坐标。若是创建控制台窗口的应用程序,这些成员用于指明控制台窗口的左上角
DWORD dwXSize; //用于设定应用程序窗口的宽度和长度(以像素为单位)只有dwYsize
DWORD dwYSize; //当子进程将C W _ U S E D E FA U LT 用作C r e a t e Wi n d o w 的
// n Wi d t h参数来创建它的第一个重叠窗口时,才使用这些值。
//若是创建控制台窗口的应用程序,这些成员将用于指明控制台窗口的宽度
DWORD dwXCountChars; //用于设定子应用程序的控制台窗口的宽度和高度(以字符为单位)
DWORD dwYCountChars;
DWORD dwFillAttribute; //用于设定子应用程序的控制台窗口使用的文本和背景颜色
DWORD dwFlags; //请参见下一段和表4 - 7 的说明
WORD wShowWindow; //用于设定如果子应用程序初次调用的S h o w Wi n d o w 将S W _ S H O W D E FA U LT 作为
// n C m d S h o w 参数传递时,该应用程序的第一个重叠窗口应该如何出现。
// 本成员可以是通常用于Show Wi n d o w 函数的任何一个S W _ *标识符
WORD cbReserved2; //保留。必须被初始化为0
PBYTE lpReserved2; //保留。必须被初始化为N U L L
HANDLE hStdInput; //用于设定供控制台输入和输出用的缓存的句柄。
//按照默认设置,h S t d I n p u t 用于标识键盘缓存,
// h S t d O u t p u t 和h S t d E r r o r用于标识控制台窗口的缓存
HANDLE hStdOutput;
HANDLE hStdError;
} STARTUPINFO, *LPSTARTUPINFO;
初始化STARTUPINFO结构体
1 | ; initialise a STARTUPINFO structure at esp |
这里edi和esp已经离得很近了,在sub edi,0x6c之前
之后
然后在edi的最后三个双字上(stdinput,stdout,stderr)都设置为eax中的套接字描述符
剩余的字节都是0
CreateProcessA
栈顶位置还啥也没写,推给eax当0用
1 |
|
这就创建了一个cmd进程,其三标都重定向到套接字了
测试
windows XP靶机和win10主机采用NAT连接方式,XP靶机的ip地址为192.168.191.137
在主机上nc 192.168.191.137 6666