汇编:32位简单函数调用的例子

1
2
3
4
5
void evilfunction(char *input)
{
    char buffer[512];
    strcpy(buffer, input);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
   0x08049166 <+0>:	push   ebp
   0x08049167 <+1>:	mov    ebp,esp
   0x08049169 <+3>:	push   ebx
   0x0804916a <+4>:	sub    esp,0x204
   0x08049170 <+10>:	call   0x80491d5 <__x86.get_pc_thunk.ax>
   0x08049175 <+15>:	add    eax,0x2e7f
   0x0804917a <+20>:	sub    esp,0x8
   0x0804917d <+23>:	push   DWORD PTR [ebp+0x8]
   0x08049180 <+26>:	lea    edx,[ebp-0x208]
   0x08049186 <+32>:	push   edx
   0x08049187 <+33>:	mov    ebx,eax
   0x08049189 <+35>:	call   0x8049040 <strcpy@plt>
   0x0804918e <+40>:	add    esp,0x10
   0x08049191 <+43>:	nop
   0x08049192 <+44>:	mov    ebx,DWORD PTR [ebp-0x4]
   0x08049195 <+47>:	leave
   0x08049196 <+48>:	ret

函数序言

1
2
3
4
push ebp       ; 保存上一个函数的 ebp
mov ebp, esp   ; 设置当前函数的 ebp
push ebx       ; 保存 ebx(被调用函数需要保存的寄存器)
sub esp, 0x204 ; 为局部变量分配栈空间(512 字节 buffer + 4 字节对齐)
  1. push ebp
1
2
执行前: esp -> [返回地址]
执行后: esp -> [上一个函数的 ebp] -> [返回地址]

保存调用者的 ebp,为了稍后恢复栈帧。

  1. mov ebp, esp 现在 ebp = esp 当前值
1
2
3
4
5
6
栈布局:
       [参数2]          (ebp+12)
       [参数1]          (ebp+8) <- 函数的第1个参数
       [返回地址]       (ebp+4)
ebp -> [旧 ebp]         (ebp+0)
       ...
  1. push ebx

ebx 是调用者需要保存的寄存器

1
2
3
4
5
6
7
 执行后:      [参数...]
               [返回地址]
               [旧 ebp]
        esp -> [旧 ebx]

现在:ebp   -> [旧 ebp]       (ebp+0)
      ebp-4 -> [旧 ebx]    <- 这就是为什么最后要 mov ebx, [ebp-0x4]
  1. sub esp, 0x204

0x204 = 516 字节(十进制) 执行后:esp 下移 516 字节,为局部变量分配空间

1
2
3
4
5
6
7
8
栈布局现在是:
ebp+8   -> [参数1]
ebp+4   -> [返回地址]
ebp     -> [旧 ebp]
ebp-4   -> [旧 ebx]
ebp-520 -> [局部变量区域开始] <- 这就是缓冲区!
           ... 
esp     -> [局部变量区域结束]   <- 当前栈指针

序言转换为 c 语言为:

1
2
3
4
5
6
void evilfunction(char *src) {
    // push ebp; mov ebp, esp; push ebx; sub esp, 0x204
    // 相当于在栈上分配了 520 字节的本地缓冲区
    char buffer[520];  // [ebp-0x208] 是缓冲区的开始
    // ... 
}

PIC(位置无关代码) 计算

1
2
3
call   0x80491d5 <__x86.get_pc_thunk.ax>
add    eax,0x2e7f
sub    esp,0x8
  1. call 0x80491d5 <__x86.get_pc_thunk.ax>

__x86.get_pc_thunk.ax 是一个特殊的函数,用于获取当前指令的地址(程序计数器 PC)。
objdump 反汇编如下:

1
2
3
080491d5 <__x86.get_pc_thunk.ax>:
 80491d5:	8b 04 24             	mov    eax,DWORD PTR [esp]
 80491d8:	c3                   	ret

在 x86 调用约定中,函数调用时会用 call 指令将返回地址(即 call 指令下一条指令的地址)压入栈顶。此时 esp 指针正好指向这个返回地址,因为这是最近一次被压入栈的数据。这样函数执行完毕后,通过 ret 指令可以从 esp 指向的位置弹出返回地址,实现返回到调用点。 执行过程:

1
2
3
4
5
6
7
8
1. call 将下一条指令的地址 push 到栈上
   push (0x08049175)  <- 下一条 add 指令的地址
   
2. jmp 到 0x80491d5
   在那里执行 mov eax, [esp]
   eax = 0x08049175  (当前指令位置)
   
3. ret 弹出栈上的返回地址,返回到 0x08049175

执行后 eax 保存了当前指令地址 0x08049175。

  1. add eax,0x2e7f

目的:计算 [email protected] 的地址。

1
2
eax = 0x08049175 + 0x2e7f
    = 0x0804aff4  (这就是 strcpy 在 GOT 表中的地址!)
  • 因为这是 PIC 代码,基地址会变化(ASLR)
  • 不能写死 GOT 地址要动态计算
  • 0x2e7f 就是 GOT 地址相对于当前代码位置的偏移

执行后 eax = [email protected] 的地址

  1. sub esp, 0x8

为 strcpy 的两个参数预留栈空间

1
2
3
4
5
6
7
8
栈布局:
ebp+8     -> [参数1 (src)]
ebp+4     -> [返回地址]
ebp       -> [旧 ebp]
ebp-4     -> [旧 ebx]
ebp-0x208 -> [缓冲区开始]
          ...
esp       -> [预留的参数空间开始]

调用 strcpy

1
2
3
4
5
push   DWORD PTR [ebp+0x8]    ; 推送 src
lea    edx,[ebp-0x208]        ; 计算 dest
push   edx                    ; 推送 dest
mov    ebx,eax                ; 保存 [email protected]
call   0x8049040 <strcpy@plt> ; 调用 strcpy

strcpy 声明:

1
char *stpcpy(char *restrict dest, const char *restrict src);
  1. push DWORD PTR [ebp+0x8]
  • [ebp+0x8] 是函数的第一个参数 input,即 src
  • 通常是一个字符串指针。
  • 推送这个值作为 strcpy 的第二个参数。
1
2
3
4
栈布局:
       ...
       [预留空间]
esp -> [src 地址]  <- strcpy 的第 2 个参数
  1. lea edx,[ebp-0x208]
  • lea = Load Effective Address(加载有效地址)
  • [ebp-0x208] 是局部缓冲区 buffer 的地址。(0x208 = 520 字节)
  • 不是读取该地址的内容,而是计算地址本身
1
2
3
4
ebp-0x208 -> [缓冲区开始]
             |
             v
             edx = ebp - 0x208  (缓冲区的地址)

执行后 edx = 局部缓冲区地址

  1. push edx

推送缓冲区地址作为 strcpy 的第一个参数 dest

1
2
3
4
5
栈布局:
       ...
       [预留空间]
       [src 地址]             <- strcpy 的第 2 个参数(源)
esp -> [dest 地址(缓冲区)]  <- strcpy 的第 1 个参数(目标)
  1. mov ebx,eax

保存 [email protected] 的地址到 ebx。

1
ebx = 0x0804aff4  ([email protected] 的地址)

PIC 代码需要保存地址,可能用于后续操作或恢复。

  1. call 0x8049040 strcpy@plt
1
2
3
4
5
// C 代码:
strcpy(dest, src);
// 其中:
// dest = [ebp-0x208]  (缓冲区地址)
// src = [ebp+0x8]     (函数参数)
  1. 当前指令地址 push 到栈上(返回地址)
  2. jmp 到 0x8049040 (strcpy@plt)
  3. strcpy 执行:
    • 从 src 读取字符,写入 dest
    • 直到遇到 ‘\0’ 为止

⚠️ 缓冲区溢出风险:
如果 src 指向的字符串长度 > 520 字节:

  • 缓冲区[0x208]只能放 520 字节
  • 多余的字节会溢出到栈上的其他数据
  • 可能覆盖保存的 ebp、返回地址等

函数收尾

1
2
3
4
5
add    esp,0x10                 ; 清理栈
nop                             ; 无操作(填充)
mov    ebx,DWORD PTR [ebp-0x4]  ; 恢复 ebx
leave                           ; 恢复栈帧
ret                             ; 返回
  1. add esp,0x10

栈指针上移 16 字节,清理 strcpy 的两个参数。

x86 32 位 cdecl 调用约定中,调用者负责清理参数栈空间。

1
2
3
4
5
6
7
             ... 
             [返回地址]
             [src]
栈前:esp -> [dest]

             ...
栈后:esp -> [返回地址]
  1. nop

无操作指令,通常用于:

  • 代码对齐
  • 填充空间
  • 调试时设置断点
  1. mov ebx,DWORD PTR [ebp-0x4]

恢复保存的 ebx 值。
ebp-0x4 存储的是函数开始时保存的旧 ebx现在读出来,恢复 ebx 的值

ebx 是被调用函数需要保存的寄存器,必须恢复原值以保证调用者的状态不被破坏。

  1. leave

等价于:

1
2
mov esp, ebp    ; 栈指针恢复到 ebp
pop ebp         ; 恢复上一个函数的 ebp

执行后:

1
2
esp -> [返回地址]
ebp = 调用者的 ebp
  1. ret

含义:

1
pop eip         ; 从栈上弹出返回地址到 eip

返回到调用者。

完整的栈帧变化过程

函数开始前

1
2
3
       [调用者的旧数据...]
       [参数 src]
esp -> [返回地址]

函数序言后

1
2
3
4
5
6
ebp       -> [旧 ebp]
ebp-4     -> [旧 ebx]
             ... 
ebp-0x208 -> [缓冲区 buffer[520]]
             ... 
esp       -> [栈顶(局部变量区)]

调用 strcpy 前

1
2
3
4
       ... 
       [缓冲区...]
       [dest 地址(参数1)]
esp -> [src 地址(参数2)]

函数结束后

1
2
3
esp -> [返回地址]  <- 准备执行 ret
ebp = 原调用者的 ebp
ebx = 原 ebx 值
萌ICP备20241614号