shellcode 是如何真正运行的?

信息安全 linux 外壳代码
2021-08-16 17:47:58

我读了《The Shellcoders Handbook》这本书,里面有一些可以执行 shellcode 的 C 代码(它只会调用 exit syscall)。

字符 shellcode[] = “\xbb\x00\x00\x00\x00\xb8\x01\x00\x00\x00\xcd\x80”;

诠释主要(){
    诠释 *ret;
    ret = (int *)&ret + 2;
    (*ret) = (int)shellcode;
}

我对主要功能中的这三行感兴趣。他们到底在做什么以及他们如何执行 shellcode?

我可能已经想通了:在堆栈上调用 main 之前,从之前的堆栈帧中推送了 ebp 和返回地址,所以在这里我们将覆盖该地址并将我们的 shellcode 放在那里。是对的吗?

1个回答

TL;DR 这是一种执行不再有效的 shellcode 的方法。

什么是函数?

Shellcode 只是在通常找不到的地方的机器代码,例如 type 的变量char在 C 中,函数和变量之间没有区别。函数只是一个指向可执行代码的变量。这意味着,如果您创建一个指向可执行代码的变量并像调用函数一样调用它,它将运行。为了说明它只是一个变量,请看这个简单的程序:

#include <stdio.h>
#include <stdint.h>

void print_hello(void)
{
    printf("Hello, world!\n");
}

void main(void)
{
    uintptr_t new_print_hello;

    printf("print_hello = %p\n", print_hello);
    new_print_hello = (uintptr_t)print_hello;
    (*(void(*)())new_print_hello)();
    print_hello();
}

当编译和执行时,这个程序给出如下输出:

$ ./a.out
print_hello = 0x28bc4bf6da
Hello, world!
Hello, world!

这使得很容易看出一个函数只不过是内存中的一个地址,与 type 兼容uintptr_t您可以看到如何将函数简单地作为变量引用,在这种情况下,通过打印其值,或将其复制到兼容类型的另一个变量并像函数一样调用该变量,尽管按顺序使用了一些转换魔法让 C 编译器高兴。一旦你看到一个函数只不过是一个指向某个可执行内存的变量,那么看看一个指向你手动定义的某个字节码的变量如何也可以被执行就不是一件容易的事了。

函数是如何工作的?

既然您知道函数只是内存中的一个地址,那么您需要知道函数实际上是如何执行的。一旦你调用一个函数,通常使用call指令,指令指针(指向当前正在执行的指令)改变为指向函数的第一条指令。调用函数之前的位置由 保存到堆栈中call一旦函数完成,它就会被ret指令终止,该指令将其从堆栈中弹出,并将其保存回 IP。所以一个(有点简化的)视图是call将 IP 推送到堆栈,然后将其ret弹出。

根据您所在的体系结构和操作系统,函数的参数可能在寄存器或堆栈中传递,返回值可能在不同的寄存器或堆栈中。这称为函数调用 ABI,它特定于每种类型的系统。为一种系统设计的 Shellcode 可能无法在另一种系统上运行,即使架构相同而操作系统不同,反之亦然。

你的shellcode是做什么的?

我们来看看你提供的shellcode的反汇编:

0000000000201010 <shellcode>:
   201010:      bb 00 00 00 00          mov    ebx,0x0
   201015:      b8 01 00 00 00          mov    eax,0x1
   20101a:      cd 80                   int    0x80

这做了三件事。首先,它将 设置ebx为 0。其次,将eax寄存器设置为 1。最后,它触发中断 0x80,在 32 位系统上,它是系统调用中断。在 SysV 调用 ABI 中,syscall 号放在 中eax,最多可以传入 6 个参数ebxecx, edx, esi, edi, 和ebp在这种情况下,ebx设置了 only,这意味着系统调用只接受一个参数。一旦调用了 0x80 中断,内核就会接管并查看这些值,执行正确的系统调用。系统调用号在 中定义/usr/include/asm/unistd_32.h看着它,我们看到系统调用 1 是exit(). 从中,我们可以看到这个 shellcode 做了三件事:

  1. 它将系统调用的第一个参数设置为 0(这意味着退出成功)。
  2. 它将系统调用号设置为 1,即退出调用。
  3. 它调用系统调用,导致程序以状态 0 退出。

当你看大图时,我们看到 shellcode 本质上等同于exit(0). 它不需要ret,因为它永远不会返回,而是导致程序终止。如果您希望函数返回,则需要添加ret到末尾。如果您至少不使用ret,那么程序将崩溃,除非它在到达函数末尾之前终止,就像您的exit()系统调用示例一样。

你的shellcode有什么问题?

您正在显示的调用 shellcode 的方法不再起作用过去是这样,但现在 Linux 不允许执行任意数据,因此需要进行一些神秘的转换。著名的Smashing The Stack For Fun And Profit文章很好地解释了这种较旧的技术:

   Lets try to modify our first example so that it overwrites the return
address, and demonstrate how we can make it execute arbitrary code.  Just
before buffer1[] on the stack is SFP, and before it, the return address.
That is 4 bytes pass the end of buffer1[].  But remember that buffer1[] is
really 2 word so its 8 bytes long.  So the return address is 12 bytes from
the start of buffer1[].  We'll modify the return value in such a way that the
assignment statement 'x = 1;' after the function call will be jumped.  To do
so we add 8 bytes to the return address.  Our code is now:

example3.c:
------------------------------------------------------------------------------
void function(int a, int b, int c) {
   char buffer1[5];
   char buffer2[10];
   int *ret;

   ret = buffer1 + 12;
   (*ret) += 8;
}

void main() {
  int x;

  x = 0;
  function(1,2,3);
  x = 1;
  printf("%d\n",x);
}
------------------------------------------------------------------------------

   What we have done is add 12 to buffer1[]'s address.  This new address is
where the return address is stored.  We want to skip pass the assignment to
the printf call.  How did we know to add 8 to the return address?  We used a
test value first (for example 1), compiled the program, and then started gdb

较新系统的 shellcode的正确版本是:

const char shellcode[] = “\xbb\x00\x00\x00\x00\xb8\x01\x00\x00\x00\xcd\x80”;

int main(){
    int (*ret)() = (int(*)())shellcode;
    ret();
}