shellcoding-basic

1. Introduction

Hello, students. I'm Yan, and this is yet another pwn.college video, specifically a video on shellcoding. This is an introduction to the shellcoding module of pwn.college. Hopefully, you have come to this module knowing computer architecture, some assembly fundamentals, and what a binary file is, and so forth. If you're missing some of this knowledge, we have a series of fundamental videos that I highly recommend you go watch before trying this module.

同学们好。我是 Yan,这是又一期 pwn.college 视频,具体来说是一期关于 shellcoding 的视频。这是 pwn.college shellcoding 模块的入门介绍。希望你在进入这个模块时,已经了解计算机体系结构、一些汇编基础、什么是二进制文件等等。如果你缺少其中某些知识,我们有一系列基础视频,我强烈建议你在尝试这个模块之前先去看一看。

Alright. Let's get started. As we saw in those computer fundamentals, specifically the computer architecture fundamental video, the modern computer architecture that is most popular, let's say, among the systems that you will interact with consciously on a daily basis, is the von Neumann architecture, named after one of these three Johns that brought us this architecture, von Neumann architecture. As opposed to some other architecture, such as the Harvard architecture. You know, kind of philosophical design treats data and code interchangeably. The von Neumann architecture is based on this model of caching layers and data flows between these caching layers—from the open internet to your disk, to your memory, to your CPU cache, to CPU registers, and so forth. And this data can be—this data can be code or data interchangeably. And this can cause problems. So let's dive into one specific example of a security problem that this can cause.

好,我们开始吧。正如我们在那些计算机基础课中所看到的,尤其是计算机体系结构基础视频里讲到的,现代最流行的计算机体系结构——至少在你每天有意识接触到的系统中是这样——是冯·诺依曼架构,以提出这一架构的三位 John 之一命名,冯·诺依曼架构。与之相对的是其他一些架构,比如哈佛架构。你知道,这是一种哲学设计,把数据和代码可互换地对待。冯·诺依曼架构基于这样一种模型:缓存层次以及这些缓存层次之间的数据流动——从开放的互联网到你的磁盘,到你的内存,到你的 CPU 缓存,到 CPU 寄存器等等。而这些数据可以——这些数据可以可互换地是代码或数据。这会导致问题。所以让我们深入看一个由此引发的具体安全问题的例子。

进入正题

image

Here's a program that a not very skilled programmer—myself—wrote. And in the course of writing this program, made several bugs. We're going to talk about one specific bug in this lecture. In future modules, we'll talk about other bugs. But this specific bug allows an attacker to achieve shellcode execution. That is to inject binary code that will be executed under the exact permissions and so forth of this program. Right, in the context of this program.

这里有一个程序,是一个不太熟练的程序员——我自己——写的。在写这个程序的过程中,我犯了好几个 bug。这节课我们要讲其中一个具体的 bug。在以后的模块中,我们会讲其他 bug。但这个具体的 bug 允许攻击者实现 shellcode 执行。也就是说,注入二进制代码,而这些代码会以这个程序的确切权限等条件被执行。对,就是在这个程序的上下文中。

image-1

So how does this happen? Well, let's take a look at this program. First thing that you see is two different functions that say bye—one function that says hello. The hello function actually takes a pointer to the bye function and invokes it. Function pointers are a feature of C and certain other programming languages that allow you at runtime to target the calling of these pointers to different places in memory. Right, so this hello function receives a memory address, and when it's time to say goodbye, it invokes that memory address. This is not a rare pattern. It's used actually quite frequently in complex codebases, passing around these function pointers.

那么这是怎么发生的呢?我们来看看这个程序。你首先看到的是两个不同的函数名称为 bye——一个函数名为 hello。hello 函数实际上接收一个指向 bye 函数的指针,并调用它。函数指针是 C 和某些其他编程语言的一个特性,它允许你在运行时把这些指针的调用目标指向内存中的不同位置。对,所以这个 hello 函数接收一个内存地址,当该调用bye函数的时候,它就跳转到那个内存地址。这不是什么罕见的模式。在复杂代码库中,这种传递函数指针的用法其实相当常见。

And you look at the main functionality of this program. You see that it first reads a name. And here's actually where there's another vulnerability that we'll deal with later. This program reads the name by—and then chooses randomly to invoke the hello function with one of the two goodbye functions, either bye_one or bye_two. And if it uses bye_two, it all looks great. There's a name that it passes—that's the name you input—and then there is the function pointer. But when it uses the first case, when it uses bye_one, it reverses the order. The programmer—myself—made a mistake. And in this tiny program, this mistake is laughable, but in a large codebase, again, this is a very easy mistake to make, just a mix-up of argument order.

然后你看这个程序的主要功能。你会看到它首先读取一个名字。而这里实际上还有另一个漏洞,我们以后再处理。这个程序读取名字的方式是——然后随机选择用两个bye函数之一来调用 hello 函数,要么 bye_1,要么 bye_2。如果它用的是 bye_two,一切看起来都很好。它传入一个名字——也就是你输入的名字——然后还有函数指针。但当它用第一种情况,也就是用 bye_1 时,它把顺序弄反了。程序员——我自己——犯了一个错误。在这个小程序里,这个错误很可笑,但在大型代码库中,这又是一个非常容易犯的错误,只是参数顺序搞混了。

So bye_one gets passed in as the name. So the name is now binary code. So the data is—or the code is being treated as data. And what's worse is my name, which is data, is now being treated as code. So what are the implications of this? The implication of this is when the bye function gets invoked, the bye function pointer—when the program execution jumps to the address that was passed into the hello function—program execution gets transferred into the stack where my name that I inputted lives. This is very bad.

所以 bye_1 被当作名字传了进去。于是这个名字现在就是二进制代码。也就是说,数据——或者说代码被当作数据对待了。更糟的是,我的名字,本来是数据,现在被当作代码对待了。那么这意味着什么呢?这意味着当 bye 函数被调用时,也就是当程序执行跳转到传给 hello 函数的那个地址时,程序执行被转移到了栈上,也就是我输入的名字所在的地方。这非常糟糕。

这里hello函数的形参中函数指针的参数被错误地传入了名为name的变量,而且name是通过gets读取的用户输入。这里实际上在调用hello函数时向bye_func这一形参的位置传入了name变量的地址,而name变量是局部变量,保存在栈上,所以程序执行流跳转到了栈上的一个地址(保存name的地址)然后开始执行。如果在用户输入name变量的值时写了一段shellcode,那么程序就会执行这一段shellcode,因为这里没有开启NX,所以栈上的机器码会被执行

Of course, modern computers have mitigations against this because this is such a common issue. Maybe not through this specific vulnerability pattern, but in general. If you're going to compile this program with these mitigations disabled—this is what's called exec stack. I'll talk in a later video about how the stack is no longer executable, so that this cannot happen, although this does not make shellcode useless; I'll talk about that in that video as well. But for the purposes of this introduction, we're going to disable that mitigation with this option.

当然,现代计算机对此有缓解措施,因为这是一个太常见的问题。也许不是针对这个具体的漏洞模式,但总的来说是这样。如果你要编译这个程序并禁用这些缓解措施——这就是所谓的 exec stack。我会在后面的视频里讲栈为什么不再可执行,从而让这种情况无法发生,尽管这并不会让 shellcode 变得无用;那个视频里我也会讲到这一点。但为了这个入门介绍的目的,我们先用这个选项禁用那个缓解措施。

image-2

So let's take a look at how this works. Alright. Here is our program, hello.c, exactly as on the slides. If we compile it—they're going to just disable all of the warnings. There's one warning that we cannot disable. And that is that you should never, ever, ever, ever use gets in anything. The gets function—it receives input until a newline and doesn't care how much input. So it'll overflow any sort of variables that you try to contain it in. All right. But we're using it because it's easy.

那我们来看看它是怎么工作的。好。这是我们的程序 hello.c,和幻灯片上完全一样。如果我们编译它——它们会直接禁用所有警告。有一个警告我们无法禁用。那就是你永远、永远、永远、永远都不应该在任何东西里使用 gets。gets 函数——它接收输入直到换行符,而且不关心输入有多少。所以它会溢出你试图容纳它的任何变量。好。但我们用它,因为它简单。

image-3

So if we run the hello function, it asks for my name. Just Janz, I hit Yaron, and luckily we hit the farewell case. If you recall, the farewell case is bye_two, and bye_two is properly passed in. Bye_one is the one that's passed in in the wrong location. Let's keep trying. On. Hit it again. Again. Boom. Program crashes. So here you can see that it printed some garbage. Most likely what happened is it printed hello, and then there was a carriage return which puts the print back to the beginning of line, and then printed more stuff. Right. And then there was an illegal instruction and the program crashed.

所以如果我们运行 hello 函数,它会问我的名字。就 Janz,我输入 Yaron,幸运的是我们命中了 farewell 那个情况。如果你还记得,farewell 情况是 bye_two,而 bye_two 是正确传入的。bye_one 才是被传错位置的那个。我们继续试。再运行一次。再来一次。Boom。程序崩溃了。所以这里你可以看到它打印了一些乱码。最可能发生的是它打印了 hello,然后有一个回车符把打印位置带回行首,然后又打印了更多东西。对。然后出现了一个非法指令,程序崩溃了。

image-4

image-7

Alright. Let's take a look a little bit deeper in GDB. So here we are in a debugger. Let's run. Starting program. Put in Yaron. Go lucky. Go lucky. Boom, okay, here's our illegal instruction. Immediately, you can see a couple of things. If you look at the process map and memory—so you can see that hello is loaded at this address here, this 5-5-4-0-0-0, and a couple of pages after that. If this isn't familiar to you, please go back and watch the Linux binary or Linux process loading video in the computer fundamentals of pwn.college. It'll be also linked from this module. I'm at pwn.college, so you can easily find that video.

好。我们在 GDB 里再深入看看。现在我们到了调试器里。我们运行。Starting program。输入 Yaron。Go lucky。Go lucky。Boom,好,这就是我们的非法指令。立刻,你可以看到几件事。如果你看进程映射和内存——你可以看到 hello 加载在这个地址,这个 5-5-4-0-0-0,以及后面几页。如果你对此不熟悉,请回去看 pwn.college 计算机基础课里的 Linux 二进制文件或 Linux 进程加载视频。它也会从这个模块链接过去。我在 pwn.college,所以你可以很容易找到那个视频。

在 GDB 中,info proc map 命令用于查看当前被调试进程的内存映射信息。它会列出被调试进程的虚拟内存区域(Virtual Memory Areas, VMAs),信息来源于 Linux 的 /proc/[pid]/maps 文件。

每行通常包含以下字段:

起始地址 结束地址 权限 偏移量 设备 inode 路径名

image-8

image-9

image-10

Alright. What we see here is that we are nowhere near that program—the location of that program.
In fact, we are in the stack where we are crashing. And we will see what is at the instruction pointer where we're crashing. We see that it is the last two letters of my name. So the first letter of my name must have been a valid instruction, which it is. Let's take a look at what that is. The first letter of my name is T—push rcx. In x86-64 assembly. Pretty cool stuff. Yeah.

好。我们在这里看到的是,我们离那个程序——那个程序所在的位置——很远。
事实上,我们在栈上,也就是我们崩溃的地方。我们会看到崩溃时指令指针处是什么。我们看到它是我名字的最后两个字母。所以我名字的第一个字母一定是一条合法指令,确实如此。我们来看看那是什么。我名字的第一个字母是 Y——push rcx。在 x86-64 汇编里。挺酷的东西。是的。

在 GDB 中,x/i $rip-1 的意思是:从 $rip - 1 这个地址开始,反汇编显示一条指令。

Okay, so we have injected shellcode. We have injected binary code directly into the application, in a way that the application will execute it. And this was super simple. It was caused by a programmer mixing up the order of two arguments. Imagine that.

好,所以我们已经注入了 shellcode。我们把二进制代码直接注入到了应用程序中,而且是以应用程序会执行它的方式。而这超级简单。它是由程序员把两个参数的顺序搞混导致的。想象一下。

image-11

Alright. This isn't just a theoretical thing. This has been seen in the wild for over 20 years. In fact, over 30 years. Robert Tappan Morris—I think he had either just graduated from Harvard or was a Harvard student at the time—created the first documented version, or the first documented example, of a computer worm, using the first documented example of shellcode and of actually buffer overflow and a lot of firsts, right? These ideas and concepts have been floating around in the wild for a while. But the Morris worm was the first to demonstrate them in practice.

好。这不仅仅是理论上的东西。这在野外已经被看到了 20 多年。事实上,30 多年。Robert Tappan Morris——我想他当时要么刚从哈佛毕业,要么还是哈佛学生——创造了第一个有记录的计算机蠕虫版本,或者说第一个有记录的计算机蠕虫例子,使用了第一个有记录的 shellcode 例子,以及实际的缓冲区溢出,还有很多第一次,对吧?这些思想和概念在野外已经流传了一段时间。但 Morris 蠕虫是第一个在实践中演示它们的。

In order to bring the Internet back up, people disconnected essentially the whole Internet, and then shut their individual networks down to fix the worm, bring them back up, and then reconnected the Internet. Imagine something shutting down the Internet today—it'd be chaos. You know, especially nowadays as I'm recording this video during the Covid-19 pandemic, where you rely on the Internet for basically everything. Interestingly, you can try to mess with the Morris worm yourself at this link down here, or at least try to exploit the vulnerability that the Morris worm took advantage of. This might be more relevant a couple of modules away. So you might not have the background knowledge needed yet, but keep that in mind. Also give it a shout out when I think it is time for you to give that exploitation a try. Alright.

为了把互联网恢复起来,人们基本上断开了整个互联网,然后关闭他们各自的网络来修复蠕虫,再重新启动,然后重新连接互联网。想象一下今天有什么东西把互联网搞瘫——那会是混乱。你知道,尤其是现在,我在 Covid-19 疫情期间录制这个视频,你基本上一切都依赖互联网。有趣的是,你可以通过下面这个链接自己试着折腾 Morris 蠕虫,或者至少试着利用 Morris 蠕虫所利用的那个漏洞。这可能再过几个模块会更有相关性。所以你可能还没有需要的背景知识,但记住这一点。等我觉得是时候让你尝试那个利用的时候,我也会提一下。

image-12

So why do we call it shellcode? Well, we call it shellcode because the typical kind of traditional goal of this exploit is to gain—get a shell on the machine, is to run something like /bin/sh. Thus, we call it shellcode. So this is an example that caused this system call execve, /bin/sh, with two null arguments. If the word "system calls" is unfamiliar to you, please go back and watch the assembly language fundamentals video or the Linux process execution fundamentals video, ideally both.

那么为什么我们叫它 shellcode 呢?我们叫它 shellcode,是因为这类利用的传统典型目标是获得——在机器上得到一个 shell,也就是运行类似 /bin/sh 的东西。因此,我们叫它 shellcode。所以这是一个例子,它触发了这个系统调用 execve,/bin/sh,带两个空参数。如果“系统调用”这个词对你不熟悉,请回去看汇编语言基础视频或 Linux 进程执行基础视频,最好两个都看。

All right, so this sets up an execve system call. 59 is the syscall number of execve. So it puts that into RAX. That's where the syscall number goes. Then it loads into the first argument slot, RDI, the address of the string /bin/sh. That's what will be executed. And we have this down here, this format, RIP plus bin_sh_offset, this is the instruction pointer plus the offset to /bin/sh. That's what this stands for. LEA stands for load effective address. And so the address of /bin/sh as it is relative from RIP gets loaded into RDI. This is very cool because this means that this shellcode just forms a block that you can put anywhere in memory, and this will find the /bin/sh path.

好,所以这设置了一个 execve 系统调用。59 是 execve 的系统调用号。所以它把那个放进 RAX。系统调用号就是放在那里。然后它把字符串 /bin/sh 的地址加载到第一个参数槽 RDI。那就是将被执行的东西。然后我们这里有这个,这个格式,RIP 加上 bin_sh_offset。所以是指令指针加上到 /bin/sh 的偏移。这就是它的意思。LEA 代表 load effective address。所以 /bin/sh 相对于 RIP 的地址被加载到 RDI。这非常酷,因为这意味着这个 shellcode 形成一个块,你可以把它放在内存中的任何地方,而它会找到 /bin/sh 路径。

Then we null out arguments 2 and 3. The way execve works—this is good. This just runs it with no arguments and no environment variables. Again, if those terms are unfamiliar to you, please go back and watch the Linux process execution fundamentals video. And then it triggers this syscall. So this is shellcode in AMD64, also known as x86-64 assembly, that gains a shell. Let's take a look at how it works.

然后我们把参数 2 和 3 置空。execve 的工作方式——这很好。这只是以无参数、无环境变量的方式运行它。再说一次,如果这些术语对你不熟悉,请回去看 Linux 进程执行基础视频。然后它触发这个系统调用。所以这是 AMD64,也就是 x86-64 汇编中的 shellcode,它获得一个 shell。我们来看看它是怎么工作的
image-13

Let's get out of our GDB session from earlier. So—yeah. Actually, I'll—bear with me. I'll take a look at how it works in a second. When I talk about how to actually compile the shellcode. So we have to talk about that first. All right. First thing I'll point out, there's this dot string thing, right. So again, von Neumann architecture—data and code all mixed up. You can put data in your shellcode. In this case, we put this string here. You can also put arbitrary bytes. There's actually a lot of other directives for putting in floating point numbers, et cetera, et cetera, et cetera. You likely, in the course of writing shellcode, will only really interact with adding data with that byte and adding data with that string. So that string adds a string and puts a null byte after it. If the concept of null-terminated strings is not familiar to you, please go back and review your C knowledge. That should be a concept that you learned when you learned C.

我们退出之前的 GDB 会话。所以——是的。实际上,我会——稍等一下。我待会儿会看它是怎么工作的。等我说到如何真正编译 shellcode 的时候。所以我们得先讲那个。好。我要指出的第一件事,是这个 dot string 东西,对吧。所以再次,冯·诺依曼架构——数据和代码全都混在一起。你可以在你的 shellcode 里放数据。在这个例子里,我们把这个字符串放在这里。你也可以放任意字节。实际上还有很多其他指令用来放浮点数,等等等等等等。你在写 shellcode 的过程中,可能只会真正接触到用那个 byte 添加数据和用那个 string 添加数据。那个 string 会添加一个字符串,并在后面放一个空字节。如果空终止字符串这个概念对你不熟悉,请回去复习你的 C 知识。那应该是你学 C 时学到的一个概念。

When you put that byte, it just dumps the bytes directly. This is one way of embedding data in your shellcode. And that's the way that we used in our example here. We put this string right here. Other ways—I'll go through one here—so you can push it onto the stack. So here, what we do is move this data to RBX, and we push RBX onto the stack. And then we move the address where the stack pointer currently points to, which is where our data was just written, to RDI. So in this case, we did the same thing. Back in the first case, this loaded the address of /bin/sh into RDI. And then this pushes RDI—pushes /bin/sh onto the stack. So on the stack, you have sh, /bin, /, such that—and you can see that this is the ASCII values of the bytes of the characters backwards, because this is a little-endian system. And we push it to the stack, and then move the stack—we take the stack pointer, which is pointing at that string we just pushed, and we move that to RDI. So in both of these routes, RDI is pointing to /bin/sh.

当你放那个 byte 时,它就直接把字节倾倒进去。这是在你的 shellcode 中嵌入数据的一种方式。这也是我们这里示例中用的方式。我们把这个字符串放在这里。其他方式——我在这里讲一种——你可以把它压到栈上。所以这里,我们做的是把这个数据移到 RBX,然后把 RBX 压入栈。然后我们把栈指针当前指向的地址,也就是我们的数据刚刚被写入的地方,移到 RDI。所以在这个例子里,我们做了同样的事。回到第一个例子,它把 /bin/sh 的地址加载到 RDI。然后它压入 RDI——把 /bin/sh 压入栈。所以在栈上,你有 sh、/bin、/,这样——你可以看到这是字符字节的 ASCII 值倒过来,因为这是一个小端系统。我们把它压入栈,然后移动栈——我们取栈指针,它指向我们刚压入的那个字符串,然后把它移到 RDI。所以在这两种路径中,RDI 都指向 /bin/sh。

image-14

There is a shellcode that is not designed to get a shell. That's not always your goal. In fact, in this class it will work as your goal, but it's much easier to just—sorry, it's much easier to just read the flag directly, although that shellcode is actually a little more complicated. So this shellcode is a shellcode that opens the /flag file and uses a syscall called sendfile to send the file over. This shellcode uses the concepts of Linux file descriptors, which is how Linux tracks files.

有一种 shellcode 并不是为了获得 shell 而设计的。那并不总是你的目标。事实上,在这门课里它会作为你的目标,但更简单的是直接——抱歉,更简单的是直接读取 flag,尽管那个 shellcode 实际上还更复杂一点。所以这个 shellcode 是一个打开 /flag 文件并使用一个叫 sendfile 的系统调用把文件发送出去的 shellcode。这个 shellcode 使用了 Linux 文件描述符的概念,这是 Linux 跟踪文件的方式

So here is the open syscall. We use the pushing method to push the /flag file name. Right, so we move /flag into RBX, we push RBX. And then later on, we move that RBX into RDI. That's the first argument to open. And then open is system call number 2. So we move 2 into RAX and then into RSI. This last argument we put a null, which is actually the value signifying to open the file read-only. And we're going to do a syscall. So this open /flag.

所以这里是 open 系统调用。我们用压栈方法把 /flag 文件名压进去。对,所以我们把 /flag 移到 RBX,压入 RBX。然后稍后,我们把那个 RBX 移到 RDI。那是 open 的第一个参数。然后 open 是系统调用号 2。所以我们把 2 移到 RAX,然后移到 RSI。最后一个参数我们放一个空值,实际上这个值表示以只读方式打开文件。然后我们要做一个系统调用。所以这就是 open /flag

And then we set up the sendfile arguments. And so for sendfile, you can pull up the man page, and I will show you that you can pull up the man page. Here, sendfile transfers data between two file descriptors. And arguments are the output file descriptor, the input file descriptor, the offset into the file in bytes—we just want to read from the beginning—and the number of bytes to read, and we'll read a bunch. And the way that we set it up is we set up the first argument to 1. So that's standard output in Linux, so that it just prints it out to the screen. The second argument is the return from the first syscall, the file descriptor of the open file. This will likely be 3, but doesn't have to be. There could be some random stuff—it's a random number, depending on how many files have previously been opened. And we read from the beginning, and we read 100. And we set all that up. The syscall number for sendfile is 40. And then we call syscall.

然后我们设置 sendfile 的参数。对于 sendfile,你可以打开 man page,我会给你展示你可以打开 man page。这里,sendfile 在两个文件描述符之间传输数据。参数是输出文件描述符、输入文件描述符、文件中的偏移量(以字节计)——我们只想从头开始读——以及要读取的字节数,我们会读一堆。我们设置的方式是,把第一个参数设为 1。那是 Linux 中的标准输出,这样它就直接打印到屏幕上。第二个参数是第一个系统调用的返回值,也就是打开文件的文件描述符。这很可能是 3,但不一定。可能是一些随机的东西——它是一个随机数,取决于之前已经打开了多少文件。我们从开头读,读 100。我们把所有这些都设置好。sendfile 的系统调用号是 40。然后我们调用 syscall。

image-15

And so now we do open, sendfile. And then here we have a syscall exit so that the shellcode exits cleanly. We didn't do that in our previous execve shellcode because execve, as discussed in the Linux loading fundamentals video, actually replaces the whole process with a new process. So it'll replace your process that you're exploiting with /bin/sh completely. This shellcode does not. So in order to avoid crashes, which make it very hard to understand that your shellcode crashed or did the program crash after your shellcode executed, we cleanly exit the program. Cool.

所以现在我们做 open、sendfile。然后这里我们有一个 syscall exit,这样 shellcode 就能干净地退出。我们在之前的 execve shellcode 里没有这么做,因为 execve,正如 Linux 加载基础视频中讨论的,实际上会用一个新进程替换整个进程。所以它会把你正在利用的进程完全替换成 /bin/sh。这个 shellcode 不会。所以为了避免崩溃——崩溃会让你很难判断是你的 shellcode 崩溃了,还是 shellcode 执行后程序崩溃了——我们干净地退出程序。酷。

这张图里的 shellcode 整体逻辑就是:打开 /flag 文件 → 把文件内容输出到屏幕 → 正常退出。
第一段:打开 /flag 文件
mov rbx, 0x00000067616c662f   ; "/flag" 的字节
push rbx
mov rax, 2                    ; syscall 号 2 = open
mov rdi, rsp                  ; 第一个参数:指向栈上的字符串 "/flag"
mov rsi, 0                    ; 第二个参数:0 = O_RDONLY,只读
syscall                       ; 执行 open("/flag", O_RDONLY)

这里有个小端序的技巧需要说明:
"/flag" 的 ASCII 是 2f 66 6c 61 67(/ f l a g)。
但 x86 是小端序,一个 8 字节立即数从低字节到高字节排列,写入内存后从低地址到高地址读出来才是 2f 66 6c 61 67。
所以程序里写的是 0x00000067616c662f,即 67 61 6c 66 2f → 反过来读就是 2f 66 6c 61 67 = /flag。
前面补了 3 个 0x00,所以字符串刚好以 \0 结尾。这利用了栈来存放字符串,不用把字符串放在数据段。
syscall 执行后,返回值(文件描述符 fd)放在 rax 里。

第二段:把文件内容输出到屏幕
mov rdi, 1        ; out_fd = 1(stdout,打印到屏幕)
mov rsi, rax      ; in_fd = open 返回的 fd
mov rdx, 0        ; offset = 0(从文件开头开始读)
mov r10, 1000     ; count = 1000(传输 1000 字节)
mov rax, 40       ; syscall 号 40 = sendfile
syscall           ; sendfile(1, fd, 0, 1000)

第 3 个参数 offset = 0 → “从头开始读”。
第 4 个参数 count = 1000 → 这里读 1000 字节(不是 100)。
注意这里 rsi 被重新赋值了:open 返回的 fd 原本在 rax(根据调用约定,返回值存储在rax),现在 mov rsi, rax 把它搬到 rsi,正好是 sendfile 第二个参数 in_fd 的位置。而 rdi 被设为 1,是标准输出。

第三段:干净退出

mov rax, 60       ; syscall 号 60 = exit
syscall           ; exit()

这就对应你之前问的那段解释:sendfile 执行完后进程还在,如果不退出会继续执行后面的垃圾内存而崩溃;所以这里显式调用 exit,让进程以正常状态结束,避免你分不清“shellcode 挂了”还是“shellcode 成功但收尾不当挂了”。

open("/flag", O_RDONLY)  → 返回 fd(通常是 3)
        ↓
sendfile(1, fd, 0, 1000) → 把 /flag 前 1000 字节打印到屏幕
        ↓
exit()                   → 干净退出



这里有个概念需要澄清一下:open、sendfile、exit 不是三个程序,而是同一个进程里依次执行的三个系统调用。

它们不是"程序",是系统调用

这段 shellcode 运行的时候,整个进程只有一个:

进程(正在执行 shellcode)
   │
   ├─ syscall: open("/flag")     ← 只是请求内核打开文件
   ├─ syscall: sendfile(...)     ← 只是请求内核传输数据
   └─ syscall: exit()            ← 请求内核结束这个进程
  • open 不是"一个程序",它是一次系统调用,执行完就返回,进程继续往下走。
  • sendfile 同理,也是一次系统调用,执行完返回,进程继续往下走。
  • 它们不会各自"退出",它们只是被这个进程依次调用的功能。

所以"sendfile 程序什么时候退出"这个问题本身不成立 —— 没有 sendfile 程序,只有一次 sendfile 系统调用,它执行完就返回了。

exit 退出的到底是谁

mov rax, 60
syscall        ; exit()

这个 exit 退出的就是当前这个正在执行 shellcode 的进程本身。

回到你之前问的那段解释:

  • 如果 shellcode 是 execve("/bin/sh"),进程会被 /bin/sh 替换,原来的进程映像没了,所以不需要 exit。
  • 但这段 shellcode 不替换进程,它只是调用 open 和 sendfile 做点事。做完之后,进程还活着。
  • 如果不调用 exit,CPU 会继续执行 shellcode 后面的内存(通常是垃圾数据),导致崩溃。
  • 所以用 exit 让这个进程正常结束。

一句话理清

说法 是否正确
"open 程序退出了" ❌ open 只是系统调用,执行完返回
"sendfile 程序退出了" ❌ sendfile 只是系统调用,执行完返回
"exit 退出了这个进程" ✅ 对,退出的是整个执行 shellcode 的进程

所以顺序是:同一个进程依次做 open → sendfile → exit。前两个是"干活然后返回",最后一个是"结束整个进程"。sendfile 不需要"退出",它返回后进程自己继续走到 exit 才结束。

所以这里是如何判断出现段错误时是shellcode有问题还是被攻击的程序本身有问题

好问题,这其实是你之前那段解释的核心目的。我们来理清"怎么区分崩溃原因"这件事。

先明确:崩溃是谁造成的

在这类漏洞利用场景里,shellcode 被注入到被攻击的进程里执行。崩溃可能来自两个阶段:

  1. shellcode 执行期间崩溃 → shellcode 本身有问题
  2. shellcode 执行完之后崩溃 → shellcode 干完了,但进程没被正确收尾

问题是:两者现象都是"进程收到 SIGSEGV(段错误)",光看信号分不出来。

exit 如何帮你区分

关键就是那段解释里说的:给 shellcode 加一个干净的出口。

情况 A:shellcode 有 bug

open(...)       ; 假设这里参数写错了
sendfile(...)   ; 或者这里寄存器用错了
exit()          ; 根本执行不到这里
  • shellcode 内部就已经触发段错误(比如访问了非法地址、跳到了错误的地方)。
  • 进程在 exit 之前就崩了。
  • 现象:进程崩溃,没有正常退出。

情况 B:shellcode 正确,但没加 exit

open(...)
sendfile(...)
                ; 这里没有 exit
                ; CPU 继续往下执行垃圾内存
  • shellcode 本身执行成功了,flag 也打印出来了。
  • 但执行完后,CPU 继续取指,跑到 shellcode 后面的垃圾内存/非法指令上。
  • 现象:flag 已经输出,但进程还是崩溃了。

情况 C:shellcode 正确,且有 exit

open(...)
sendfile(...)
exit()          ; 干净退出
  • shellcode 正常执行完,flag 打印出来,然后进程以状态码 0 正常结束。
  • 现象:flag 输出,进程正常退出,没有崩溃。

判断逻辑

现象 结论
flag 没输出,进程崩溃 shellcode 有问题(情况 A)
flag 输出了,进程崩溃 shellcode 其实成功了,是没加 exit 导致收尾崩溃(情况 B)
flag 输出了,进程正常退出 shellcode 完全正确(情况 C)

如果没有 exit,情况 B 和情况 A 都表现为"崩溃",你就无法区分:

  • 是 shellcode 根本没跑通?
  • 还是 shellcode 跑通了,只是最后没收尾?

加上 exit 之后,情况 B 被消除了(因为不会再有"执行到垃圾内存"这一步)。于是:

  • 崩溃 = shellcode 真有问题
  • 正常退出 = shellcode 没问题

这就是"干净退出"的价值 —— 它把"收尾不当导致的崩溃"这个干扰项消掉,让崩溃信号只指向 shellcode 本身的 bug。

实际调试时通常看:

  • 退出状态码:echo $?,正常 exit 是 0;被信号杀死通常是 128+信号号(段错误 SIGSEGV=11 → 139)。
  • 是否收到信号:gdb 里能看到 Program received signal SIGSEGV。
  • 输出内容:flag 有没有打印出来。

结合起来:有 flag 输出 + 正常退出码 = shellcode 成功;没 flag + 段错误 = shellcode 失败;有 flag + 段错误 = 没加 exit 的经典症状。

一句话总结:
exit 的作用不是让 shellcode "能跑",而是给 shellcode 一个明确的成功终点。有了它,崩溃就只可能来自 shellcode 内部;没有它,崩溃可能只是执行完 shellcode 后跑到垃圾内存,从而掩盖真相、让你误判 shellcode 有问题。

进入构建shellcode的部分

image-16

Alright. Now, finally, we have moved on to building our shellcode. So we write our shellcode as assembly code. There's some boilerplate that we need to set up a global symbol that is the start of the shellcode, that when we assemble it into a binary file, we can actually just execute it like a normal file. This really helps debugging. And we say, okay, here's where the start label is. And then we say, by the way, we're using Intel syntax. By default, the tools we'll be using use AT&T syntax. It's very annoying. It is the wrong syntax. But luckily, we can use an option to use Intel syntax. And here we are setting up our /bin/sh shellcode that we looked at earlier.

好。现在,终于,我们进入了构建 shellcode 的部分。我们把 shellcode 写成汇编代码。有一些样板代码,我们需要设置一个全局符号,作为 shellcode 的起点,这样当我们把它汇编成二进制文件时,我们实际上可以像普通文件一样执行它。这对调试非常有帮助。我们说,好,这里是 start 标签所在。然后我们说,顺便说一下,我们使用 Intel 语法。默认情况下,我们将要使用的工具使用 AT&T 语法。这很烦人。它是错误的语法。但幸运的是,我们可以用一个选项来使用 Intel 语法。这里我们正在设置我们之前看过的 /bin/sh shellcode。

Then we assemble it. We'll assemble it into an ELF file, into a program that we can run. We assemble it without the standard libraries because we don't need them. We don't use them. We directly call using system calls into the kernel. And we create a static file also because this makes it easier in certain situations. It doesn't invoke the dynamic loader at all. Nothing is—just, here is your shellcode running directly in the south. That's pretty exciting.

然后我们汇编它。我们把它汇编成一个 ELF 文件,一个我们可以运行的程序。我们汇编它时不带标准库,因为我们不需要它们。我们不用它们。我们直接用系统调用进入内核。我们还创建一个静态文件,因为这在某些情况下更容易。它完全不调用动态加载器。什么都没有——只是,这里是你的 shellcode 直接在底层运行。这挺令人兴奋的

This is, by the way, this produces a program, not shellcode. It produces an actual program with an ELF header, et cetera, et cetera. As you should know. And if you don't, please go back and watch the fundamentals lecture on what is a binary file. Your ELF file contains a dot text section with the shellcode—with the code of your program. In this case, the whole code of your program is the shellcode. And you can use objcopy to copy that shellcode out. And now that resulting shellcode.raw file are the bytes of your shellcode that you would inject in your exploit. That's pretty exciting. So let's take a look at how this would work.

顺便说一下,这会生成一个程序,而不是 shellcode。它生成一个带有 ELF 头等等的实际程序。你应该知道这一点。如果你不知道,请回去看关于什么是二进制文件的基础讲座。你的 ELF 文件包含一个 .text 段,里面有 shellcode——有你程序的代码。在这个例子里,你程序的全部代码就是 shellcode。你可以用 objcopy 把那个 shellcode 复制出来。现在得到的 shellcode.raw 文件就是你要在利用中注入的 shellcode 字节。这挺令人兴奋的。那我们来看看这会怎么工作。

image-17

All right. Here is our shellcode, shellcode.s. It's the same shellcode that's on the slides, just calls exactly the same. If we compile it—as -o shellcode.o shellcode.s, no wait, as -o shellcode.o shellcode.s. We don't need the standard library, -static just to simplify some things. Shellcode.s to shellcode.elf. It compiles. And when you run shellcode.elf—boom. It runs /bin/sh, and we have a shell. Of course, we had a shell before, but no one is complaining.

好。这是我们的 shellcode,shellcode.s。它和幻灯片上的 shellcode 一样,调用完全一样。如果我们编译它——as -o shellcode.o shellcode.s,不,等等,as -o shellcode.o shellcode.s。我们不需要标准库,-static 只是简化一些事情。Shellcode.s 到 shellcode.elf。它编译了。当你运行 shellcode.elf——boom。它运行 /bin/sh,我们就有了一个 shell。当然,我们之前也有一个 shell,但没人抱怨。

image-18

Let's disassemble it real quick, just to see that it has our shellcode in Intel format, of course. And here it is, nice and simple. In the start of the program. Here we have our move RAX, 59. That is 0x3B in hexadecimal. LEA the address of RIP plus 0x10. That's our /bin/sh. We move zero out RSI and RDX. That is the argv and the environment. Then we trigger the syscall. Cool.

我们快速反汇编一下,当然,只是为了看看它有我们的 shellcode,Intel 格式。它就在这里,简单明了。在程序的开头。这里我们有 mov RAX, 59。那是十六进制的 0x3B。LEA RIP 加 0x10 的地址。那是我们的 /bin/sh。我们把0赋值给 RSI 和 RDX。那是 argv 和环境变量。然后我们触发系统调用。酷。

里的0x10是"跳过后面那几条指令"的字节距离

image-19

So let's copy out—this will dump the text segment into our text section. objcopy -O binary -j .text shellcode-elf into our shellcode-raw file. Alright. And now we can hexdump shellcode.raw. And you can see it's the same bytes. 48 C7 C0 3B and so on all the way to the end of /bin/sh. I should point out one thing: when we were disassembling our shellcode, of course, not knowing any better, objdump tried to disassemble /bin/sh as well. You'll have to deal with that on your own. All right. I mean, it's just—yeah. How it happens to work. But here's our shellcode. This is the shellcode. And we can now, Transfer directly into our vulnerable program.

所以我们复制出来——这会把 text 段复制到我们的 text 段。objcopy -o binary -j .text shellcode-elf 到我们的 shellcode-raw 文件。好。现在我们可以 hexdump shellcode-raw。你可以看到它是同样的字节。48 C7 C0 3B 等等,一直到 /bin/sh 的末尾。我应该指出一件事:当我们反汇编我们的 shellcode 时,当然,objdump 不知道更好,它也会尝试反汇编 /bin/sh。你得自己处理那个。好。我的意思是,它只是——是的。它碰巧能工作。但这是我们的 shellcode。这就是 shellcode。我们现在可以,直接转移到我们有漏洞的程序里。

image-23

So, we can—cat out this shell code into our hello. As a reminder, hello. Put in my name, and that gets executed directly as shell code. This is going off script a little bit.

所以,我们可以——把这段 shellcode cat 到我们的 hello 里。提醒一下,hello。输入我的名字,然后它就直接作为 shellcode 被执行。这有点脱离剧本了。

image-24

I didn't actually think to run—yes. And what I'm doing right here is, first, printing out the shell code, and then just dropping into an interactive—like a cat that reads my input and writes the output. Otherwise, I'll cut out the shell code. Run hello. It'll run /bin/sh. And then /bin/sh will terminate immediately because there's no further input, because the shell code—so is done.

我其实没想到要运行——是的。我在这里做的是,首先打印出 shellcode,然后只是进入一个交互式的——就像 cat 读取我的输入并写出输出。否则,我会把 shellcode 剪掉。运行 hello。它会运行 /bin/sh。然后 /bin/sh 会立即终止,因为没有更多输入了,因为 shellcode——就这样结束了。

All right. Here we go. Whoops. cat shellcode.raw. We have a shell. I hit enter. Because gets will keep reading until a new line appears. And I didn't put a new line in. Let's actually—put a new line in. So this will echo a new line. And this will append it to the shellcode.raw file. Now, let's hexdump shellcode.raw. There's now a new line here. That's very cool. Then we run it again. So. It did farewell. So it didn't hit the right case. There—now it hit the right case. And now we are in—we have a shell on my machine. Through an exploit. By injecting shell code. As my user. Pretty cool. Can hit exit. And now that program's dead.

好。我们来了。哎呀。cat shellcode.raw。我们有了一个 shell。我按了回车。因为 gets 会一直读到换行符出现为止。而我没有放一个换行符进去。我们实际上——放一个换行符进去。所以这会 echo 一个换行符。然后这会把它追加到 shellcode.raw 文件里。现在,我们 hexdump shellcode.raw。这里现在有一个换行符了。这很酷。然后我们再运行一次。于是。它执行了 farewell。所以它没有命中正确的情况。那里——现在它命中了正确的情况。现在我们在——我们在我的机器上有了一个 shell。通过一个 exploit。通过注入 shellcode。以我的用户身份。挺酷的。可以按 exit。然后那个程序就死了。

image-25

All right, so that is how shell code works. We've now built shell code. What's next? We already saw how to run it. Actually, I jumped ahead a little bit. But just to reiterate, the nice thing about compiling it in this—assembling your shell code in this method that I've taught you—is that you can run it directly and also debug it and so forth, as we'll get to very shortly.

好,这就是 shellcode 的工作方式。我们现在已经构建了 shellcode。接下来呢?我们已经看到了怎么运行它。实际上,我有点跳得太快了。但只是再重申一下,用这个——用我教你的这种方法汇编你的 shellcode——的好处是,你可以直接运行它,也可以调试它等等,我们很快就会讲到。

Interesting thing here, there are a lot of different methods to write shell code and specifically to assemble shell code. There are other assemblers instead of using tooling built into GCC. There are other assemblers such as NASM and various others. You can use those. But in my experience, it makes things a little bit harder for reasons that you won't benefit from for this course. I'd recommend using the method I taught because that method is very easy to debug, very applicable, and actually is super applicable to other architectures than AMD64 as well, whereas other assemblers tend to be specific for one architecture.

这里有意思的是,写 shellcode、尤其是汇编 shellcode 有很多不同的方法。除了使用 GCC 内置的工具链之外,还有其他汇编器。有像 NASM 以及其他各种汇编器。你可以用那些。但根据我的经验,它会让事情稍微难一点,原因是你在这门课里不会从中受益。我建议用我教的方法,因为那个方法非常容易调试、非常适用,而且实际上对 AMD64 之外的其他架构也超级适用,而其他汇编器往往只针对一种架构。

If you're not happy with the shell code runner that gets compiled as your ELF file, which is just basically an ELF wrapper around your shell code, you can create a C program that loads your shell code, maps it as executable into memory, and executes it. This might be useful when you need to simulate really tricky conditions—open up different files, stuff like that—and you don't want to write that all in assembly as a preamble to your shell code. And this is generally how you would do it. Right, you would create a pointer—a function pointer called page memory map, a bunch of code that's executable. Read your shell code into it. And execute it. All right. And then you inject that in the same way that we injected our shell code into that tester program.

如果你不满意那个被编译成 ELF 文件的 shellcode runner——它基本上就是一个包着你的 shellcode 的 ELF 包装——你可以创建一个 C 程序,加载你的 shellcode,把它作为可执行内存映射进去,然后执行它。当你需要模拟非常棘手的条件——打开不同的文件之类的——而你不想把那些都用汇编写成 shellcode 的前导代码时,这可能很有用。这通常就是你做这件事的方式。对,你会创建一个指针——一个函数指针,叫做 page memory map,一堆可执行的代码。把你的 shellcode 读进去。然后执行它。好。然后你用它注入,方式和我们把 shellcode 注入那个测试程序一样。

image-20

How do you debug shell code? Let's say shell code is going wrong. Something is driving you insane and things aren't working. Well, one way from a high level is to use strace. Strace, as you should know from the fundamental series, is a system call tracer. It will simply run your program with debugging and print out all the syscalls that your program does. So, let's take a look.

你怎么调试 shellcode?假设 shellcode 出问题了。有什么东西快把你逼疯了,东西不工作。嗯,从高层次来说,一个办法是用 strace。Strace,正如你应该从基础系列里知道的,是一个系统调用跟踪器。它会简单地带着调试运行你的程序,并打印出你的程序做的所有系统调用。那我们来看看。

image-26
image-27
image-28

Actually, first, let's break our shell code. Yeah. How about instead of /bin/sh, let's actually have it go /bin/sh plus 1. I have no idea if this will work, but let's compile it. Okay, it works. All right. Or rather, it assembled. So shellcode.elf. We run it. Illegal instruction. Well, that's bad news. What went wrong with our shell code? If we strace it. And this is an awesome result of the shell code being inside an ELF file and easily runnable. If we strace it, we see right away what's wrong. It executed bin slash sh instead of slash bin slash sh and got a no such file or directory as a result. Cool. Then we modify it. Fix it. Reassemble it. Rerun it. And then we see that it is actually now executing /bin/sh. And if you scroll up, it's right here. Very cool.

实际上,先让我们把 shellcode 弄坏。对。要不别用 /bin/sh,我们让它去 /bin/sh 加 1。我不知道这会不会行,但我们编译一下。好,它行了。好。或者说,它汇编了。所以 shellcode.elf。我们运行它。非法指令。嗯,那可不是好消息。我们的 shellcode 出什么问题了?如果我们 strace 它。这是 shellcode 位于 ELF 文件里并且容易运行的一个很棒的结果。如果我们 strace 它,我们马上就看到问题在哪。它执行了 bin slash sh,而不是 slash bin slash sh,结果得到了 no such file or directory。酷。然后我们修改它。修好它。重新汇编它。重新运行它。然后我们看到它现在实际上在执行 /bin/sh。如果你往上滚,它就在这里。非常酷。

image-29

All right. So that is strace. That's how you debug your shell code at a high level. And this catches a shocking amount of errors that you make in shell coding. If you need something more, you can debug shell code with GDB. It's just a Linux program, the way that we assemble it. Right, so let's take a quick look. And then this slide can be a good reference for you.

好。这就是 strace。这就是在高层次上调试 shellcode 的方法。这能抓住你在 shellcoding 中犯的惊人数量的错误。如果你需要更多,你可以用 GDB 调试 shellcode。它就是一个 Linux 程序,按照我们汇编它的方式。对,那我们快速看一下。然后这张幻灯片可以给你当个不错的参考.

image-30

If we, again, do something silly to our shell code. If we—I don't know what—what might we do? We might accidentally have some dereference here. That's not good. Right, so okay, we were loading it to RDX. Memory that our RSI is pointing to, but our RSI is pointing to 0x220. So it's going to crash. All right. But this will assemble. And when you run it, it's segfault. Of course. Run it in GDB. shellcode.elf. Run it. Okay, it segfaulted. Immediately, we can see what—where did it segfault? It segfaulted at this move into our RDX. The pointer to our RSI. You can see what is RSI right now. Or what is it pointing to. And we see that it is 0. And so can't be your reference.

如果我们再一次对我们的 shellcode 做点傻事。如果我们——我不知道什么——我们可能会做什么?我们可能在这里不小心有个解引用。那可不好。对,好,我们把它加载到 RDX。我们的 RSI 指向的内存,但我们的 RSI 指向 0x220。所以它会崩溃。好。但这会汇编通过。当你运行它时,它就是段错误。当然。在 GDB 里运行它。shellcode.elf。运行它。好,它段错误了。立刻,我们可以看到什么——它在哪里段错误了?它在这条 move 到我们的 RDX 处段错误了。指向我们 RSI 的指针。你可以看到 RSI 现在是什么。或者它指向什么。我们看到它是 0。所以不能解引用。

这行新添加的 mov rdx, [rsi] 会造成 段错误(Segmentation Fault),让 shellcode 直接崩溃,根本执行不到 syscall。

为什么

看前面的代码:

mov rsi, 0        ; rsi = 0  ← 关键在这
mov rdx, 0        ; rdx = 0
mov rdx, [rsi]    ; 新增:rdx = *(rsi) = *(0)  ← 读地址 0 的内容
                  ; rsi=0 → 去地址 0 读 8 字节
syscall
  • mov rsi, 0 把 rsi 设成了 NULL(地址 0)。
  • 新增的 mov rdx, [rsi] 是解引用:去读 rsi 指向的内存,也就是读地址 0。
  • 在 Linux 上,地址 0 所在的页被刻意映射为不可访问(这是为了捕获空指针解引用)。
  • 读地址 0 → 触发 SIGSEGV 段错误 → 进程直接崩,syscall 永远执行不到。

它想干什么(以及为什么错)

原本 mov rdx, 0 是把 execve 的第三个参数 envp 设为 NULL。新增的 mov rdx, [rsi] 可能被误以为能"取 rsi 指向的值",但:

  • 汇编里 [rsi] 表示内存解引用,不是"把 rsi 的值给 rdx"。
  • 要把 rsi 的值给 rdx,应该用 mov rdx, rsi(没有方括号)。
  • 而且即便用 mov rdx, rsi,这里 rsi 已经是 0,结果还是 0,等价于原来那行。

顺带一提:和 binsh 标签的冲突

如果这行指令插在 syscall 之前,它会改变指令长度布局。原本:

lea rdi, [rip+0x10]   ; 0x10 = 跳过后面 16 字节到 /bin/sh
mov rsi, 0
mov rdx, 0
syscall

现在多了一条 mov rdx, [rsi](3 字节),/bin/sh 相对 lea 的距离变成 0x13 而不是 0x10。如果不改 lea 里的偏移,rdi 就会指错地方,execve 拿到的路径不是 /bin/sh。

不过在这个具体例子里这不是重点——因为还没走到 lea 后面就已经在 mov rdx, [rsi] 处段错误了。

总结

项 结果
直接后果 读地址 0 → SIGSEGV 段错误
能否到 syscall ❌ 到不了
根本原因 rsi=0,[rsi] 解引用空指针
想表达的意思应该是 mov rdx, rsi(但此处 rsi=0,仍等价于 0)
副作用 改变指令长度,破坏 lea rip+0x10 的偏移

一句话:mov rdx, [rsi] 在 rsi=0 的前提下就是解引用空指针,必崩。
alt text
Now, we know what's wrong.

现在我们知道哪里错了。

alt text

alt text
alt text

Let's take a quick look at some other commands—some other useful things we can do in GDB. Keep in mind, breakpoints—because there are no labels—or actually there can be labels. If you put in the labels, so you can actually set, for example, in GDB, although this makes no sense, break /bin/sh. And it sets it. And you can actually, if that was ever run. But actually here, you can add more labels, right. These labels—I mean, nothing really. You say, okay, I'll label 1. Label 2. And then, when we assemble it, GDB we need to break label1. Break label2. Run. And we hit the breakpoint, label1. This is awesome functionality to be able to do for your shell code. And it doesn't actually modify your shell code at all to have these labels. You can still extract your shell code. The labels are all in other parts of the ELF. It just makes debugging much, much easier.

我们快速看一下 GDB 里其他一些命令——其他一些我们可以做的有用的事情。记住,断点——因为没有标签——或者实际上可以有标签。如果你把标签放进去,你实际上可以设置,比如在 GDB 里,虽然这没什么意义,break /bin/sh。然后它设置好了。而你可以实际上,如果那个曾经被运行。但实际在这里,你可以加更多标签,对。这些标签——我是说,其实没什么。你说,好,我要 label1。Label2。然后,当我们汇编它时,GDB 我们需要 break label1。Break label2。Run。然后我们命中了断点,label1。这是能够为你的 shellcode 做的一件很棒的功能。而且有这些标签实际上完全不会修改你的 shellcode。你仍然可以提取你的 shellcode。标签都在 ELF 的其他部分。它只是让调试变得容易得多得多。

alt text

Other things that we can do—if you don't want to use those labels, just keep in mind before the address where you want to break, you have to put a star. And one of the reasons that we compiled things statically—I'll talk about another reason later. If you recall, this creates a static ELF. Again, we talked about static and dynamic ELFs in the binary files fundamentals. But one of the reasons is when you have a static ELF, it always maps—or it gets compiled in a way by default that it maps everything into this fixed address instead of random memory. So it's a little easier to debug. All right.

其他我们可以做的事情——如果你不想用那些标签,只要记住,在你想打断点的地址前面,你必须放一个星号。我们静态编译东西的原因之一——我稍后会讲另一个原因。如果你还记得,这会创建一个静态 ELF。再说一次,我们在二进制文件基础里讲过静态和动态 ELF。但其中一个原因是,当你有一个静态 ELF 时,它总是映射——或者说它默认编译成把所有东西都映射到这个固定地址,而不是随机内存。所以调试起来稍微容易一点。好。

If you want to break at a memory location, just remember, put a star in front of it. A couple of things that work kind of weirdly—in source code, you're used to hitting—when you're in GDB debugging a C program that you built with -g for the GDB debug symbols, you're probably used to hitting s to step one line of code. This isn't source code. This is assembly code. si will step one instruction. Don't use s, use si. si will follow call instructions. Sometimes this is what you want. Sometimes you want everything that you want that call to execute, and then you want to get control again. That is ni. Next instruction. So next instruction will step over a call. Of course, the call will execute, and then you'll get your GDB shell back. si will step into the call. Both are useful, but it's important to know the difference.

如果你想在某个内存位置打断点,只要记住,在前面放一个星号。有几件事用起来有点怪——在源代码里,你习惯按——当你在 GDB 里调试一个用 -g 编译的、带 GDB 调试符号的 C 程序时,你可能习惯按 s 来单步一行代码。这不是源代码。这是汇编代码。si 会单步一条指令。不要用 s,用 si。si 会跟进 call 指令。有时候这是你想要的。有时候你想要那个 call 执行的一切,然后你想重新获得控制权。那就是 ni。Next instruction。所以 next instruction 会步过 call。当然,call 会执行,然后你会拿回你的 GDB shell。si 会步入 call。两者都有用,但知道区别很重要。

这段话是在讲 GDB 调试汇编代码时,单步执行该用哪个命令。核心是区分 s / si / ni 的区别和适用场景。

为什么不能用 s

mov rax, 59
lea rdi, [rip+binsh]
mov rsi, 0
syscall
  • s(step)是按源代码行单步的。
  • 但你现在看的是汇编代码,不是源代码,没有源码行可对应。
  • 在汇编层面用 s 行为不可靠,可能跳过很多指令或行为不符合预期。
  • 所以原文说:"这不是源代码。这是汇编代码。不要用 s,用 si。"

si —— Step Instruction(单步一条指令)

si    ; 执行当前这一条汇编指令,然后停下
  • 精确执行一条机器指令,无论它是不是 call。
  • 遇到 call 时,si 会步入(step into)被调用的函数/代码。
  • 也就是"跟进 call 里去看看里面在干嘛"。

ni —— Next Instruction(步过一条指令)

ni    ; 执行当前这一条汇编指令,但如果它是 call,就整个执行完再停
  • 也是执行一条指令。
  • 但遇到 call 时,ni 会步过(step over):让整个被调用的函数执行完,控制权回到 call 的下一条指令,再停下。
  • 也就是"不关心 call 里面,只关心它执行完的结果"。

核心区别:si vs ni 遇到 call 时

假设当前指令是:

call some_function     ; ← 你在这里
mov rax, 0             ; 下一条指令
命令 行为 停在哪
si 进入 some_function 内部 停在 some_function 的第一条指令
ni 执行完 some_function 停在 mov rax, 0

什么时候用哪个

原文说得很清楚:

si 会跟进 call 指令。有时候这是你想要的。有时候你想要那个 call 执行的一切,然后你想重新获得控制权。那就是 ni。

  • 想看 call 内部逻辑 → 用 si(步入)。
  • 不想管 call 内部,只想让它跑完 → 用 ni(步过)。

对比总结

命令 全称 行为 遇到 call
s step 单步源码行 步入(源码层面)
si step instruction 单步一条指令 步入 call
ni next instruction 执行一条指令 步过 call

为什么在 shellcode 调试里特别重要

你调试的是汇编写的 shellcode:

  • 没有 C 源码,s 没用 → 必须用 si。
  • shellcode 里调用的往往是系统调用(syscall)或内部跳转:
    • 想看 syscall 之后寄存器怎么变 → si 执行它。
    • 如果 shellcode 里有个 call 到某段子程序,你既可以选择进去(si),也可以选择让它跑完(ni)。
  • 用 ni 跳过不关心的 call,能快速走到你真正想看的位置,不用一步步陷进无关代码。
    类比

把 call 想成"打了个电话":

  • si = 你走进电话亭,全程旁听这通电话。
  • ni = 你等这通电话打完,直接拿结果,不关心里面说了啥。

两者都有用,看你是想深入细节还是快速推进。

一句话总结

调试汇编时不要用 s,用 si;si 单步一条指令并会步入 call,ni 单步一条指令但会步过 call。想进函数内部就用 si,想跳过函数直接拿结果就用 ni——知道两者区别很重要。

image-36

There are different ways that you can look at data with these modifiers. This means examine the data that our RSB points to and treat it as one gigantic word. And this is 8 bytes in hexadecimal format. Other options for the hexadecimal format include string. You saw me use that at the beginning when we were looking at hello, the program. i for integer—no, sorry, i for instruction. And I think d for decimal integer and x for hex. And then you can, of course, use p to just print out the value of this expression or this register without dereferencing them. And we have g, 8 bytes. The w, 4 bytes. h, 2 bytes. And b, 1 byte. And then you can print multiple. So all of these invocations will print 8 bytes of whatever you're looking at.

你可以用这些修饰符以不同的方式查看数据。x/gx $rsp 这意味着检查我们的 rsp 指向的数据,并把它当作一个巨大的字来处理。这是十六进制格式的 8 个字节。十六进制格式的其他选项包括 string。你看到我在开头看 hello 那个程序时用过那个。i 代表 integer——不,抱歉,i 代表 instruction。 d 代表十进制整数,x 代表十六进制。然后你当然可以用 p 来只打印这个表达式或这个寄存器的值,而不解引用它们。然后我们有 g,8 字节。w,4 字节。h,2 字节。b,1 字节。然后你可以打印多个。所以所有这些调用都会打印 8 字节的你正在看的东西。

run and continue, and all of this stuff works as normal. It's just a Linux program that just happens to only contain your shell code. And, in fact, a useful thing is that reverse execution—and you can go follow this link and reverse execution also works as normal. So I can check all that out.

运行和继续,所有这些东西都正常工作。它就是一个 Linux 程序,只是碰巧只包含你的 shellcode。而事实上,一个有用的事情是反向执行——你可以跟着这个链接,反向执行也正常工作。所以我可以把这些都看看。

image-37
image-38

Right. Final thing we'll talk about in terms of debugging your shell code is hard coding in breakpoints. So it turns out that when you create a breakpoint in GDB—I mean, there are several different styles of breakpoints. When you create a normal default software breakpoint in GDB, it'll actually replace the instruction where you have a breakpoint with an int3 instruction. int3 instruction is a single—instruction that decodes into 0xCC. You can actually—or that encodes, sorry, assembles into the byte 0xCC. You can look at it. Right here. Where I will now try it out. shellcode.s. So let's take away this buggy thing. And instead—take with these labels. Instead, we'll put an int3 here. So this will inject a hard-coded breakpoint into our shell code right before this call gets run. Let's assemble it. Great.

对。关于调试你的 shellcode 我们要讲的最后一件事是硬编码断点。事实证明,当你在 GDB 里创建一个断点时——我是说,有几种不同风格的断点。当你在 GDB 里创建一个普通的默认软件断点时,它实际上会把你有断点的那个指令替换成一条 int3 指令。int3 指令是一条单一的——一条解码成 0xCC 的指令。你可以实际上——或者说它编码成,抱歉,汇编成字节 0xCC。你可以看看它。就在这里。我现在要试一下。shellcode.s。所以我们把这个有 bug 的东西拿走。然后——拿带这些标签的。取而代之,我们在这里放一个 int3。所以这会在我们的 shellcode 里、就在这个 call 被运行之前,注入一个硬编码的断点。我们汇编它。很好。

image-39

If you run it now, it's going to crash because we don't have a debugger attached, and we're not capturing this SIGTRAP. If we run it with GDB. Ooh. We get into a breakpoint at exactly where we expected to right before that syscall. Very cool. So this is useful. I mean, of course, in GDB, normally you can just say, okay, break at the label or break at the address. But when you're actually debugging the vulnerable program that you're exploiting, and you want to break at the syscall, this is much harder because you don't always know where your shell code will end up, and so forth, to put in—to set the debugger, the breakpoints in GDB. So if you put a breakpoint at the beginning of your shell code, assemble it.

如果你现在运行它,它会崩溃,因为我们没有附加调试器,我们没有捕获这个 SIGTRAP。如果我们用 GDB 运行它。哦。我们正好在预期的位置、就在那个系统调用之前进入了一个断点。非常酷。所以这很有用。我的意思是,当然,在 GDB 里,通常你只要说,好,在标签处打断点,或者在地址处打断点。但当你实际上在调试你正在利用的那个有漏洞的程序,而你想在那个系统调用处打断点时,这要难得多,因为你不总是知道你的 shellcode 会最终到哪里等等,来放——来在 GDB 里设置调试器、设置断点。所以如果你在你的 shellcode 开头放一个断点,汇编它。

这段话讲的是 在 GDB 里给 shellcode 打断点的实际问题:为什么在系统调用处打断点很难,以及为什么最好在 shellcode 开头打断点。

先理解 GDB 打断点的两种常规方式

原文说:

通常你只要说,好,在标签处打断点,或者在地址处打断点。

GDB 里打断点一般就这两种:

break _start          # 在标签(符号)处打断点
break *0x401000       # 在具体地址处打断点
  • 标签处:需要符号表(比如 -g 编译,或汇编里有全局标签)。
  • 地址处:需要知道确切的绝对地址。

为什么在"系统调用处"打断点很难

关键在原文这句:

当你实际上在调试你正在利用的那个有漏洞的程序,而你想在那个系统调用处打断点时,这要难得多,因为你不总是知道你的 shellcode 会最终到哪里等等。

拆开来看:

  1. 你不知道 shellcode 会被加载到哪个地址
  • 调试的是被攻击的有漏洞程序(比如 ./hello)。
  • shellcode 是通过注入(stdin、argv、环境变量等)进到目标进程的。
  • 它最终落在哪个内存地址,取决于:
    • 目标的缓冲区在哪(栈地址、堆地址)。
    • ASLR 是否开启。
    • 你用了什么注入方式。
  • 所以你事先不知道 shellcode 的绝对地址,没法直接 break *0x...。
  1. 你甚至不知道注入是否成功、会不会走到 syscall
  • shellcode 可能根本没被正确执行(起点错了、被截断了)。
  • 可能执行到一半就崩了。
  • 所以在 syscall 那条指令处下断点,前提是"程序真能走到那",而这本身就不确定。
  1. syscall 在汇编里没有稳定的符号名
  • 如果是源码,你可以 break open、break execve(库函数符号)。
  • 但 shellcode 里的 syscall 是一条裸指令,没有符号、没有标签。
  • 你只能靠地址去下断点,而地址你又不知道。
  1. 断点本身会改变执行环境
  • 在 shellcode 内部下断点,GDB 要往那里写 int3(0xCC)指令。
  • 如果 shellcode 在只读/不可写的内存里,或者被校验,断点可能设不上或破坏 shellcode。
  • 这进一步增加了在"精确位置"下断点的难度。

为什么要在 shellcode 开头打断点

原文的结论:

所以如果你在你的 shellcode 开头放一个断点,汇编它。

意思是:在你的 shellcode 源码最开头加一个断点指令,然后重新汇编。

具体做法通常是这样:

方法一:在源码开头插 int3

_start:
    int3              ; ← 断点指令,机器码 0xCC
    mov rax, 59
    lea rdi, [rip+binsh]
    ...
  • int3 是 x86 的断点指令(1 字节,0xCC)。
  • 执行到它时,会触发一个断点异常,GDB 会自动捕获并停下。
  • 等价于在 shellcode 的第一条指令处下了一个断点。

方法二:GDB 里在入口处断

break _start          # 如果你有符号
break *0x...          # 如果你知道加载地址

但如前所述,地址不一定知道,所以在源码里直接写 int3 更可靠。

为什么开头打断点就解决了问题

一旦程序在 shellcode 开头停下:

  1. 确认 shellcode 确实被执行了(能停在这,说明控制权成功转移过来了)。
  2. 拿到当前 RIP 的值 —— 也就是 shellcode 被加载到的真实地址!
    p/x $rip        # 打印当前指令地址
    
  3. 有了这个真实地址,你就能算出后面每条指令的地址,再精确下断点:
    break *0x<真实地址 + 偏移>
    
  4. 然后就可以 si / ni 一步步跟踪,看它到底走到哪、在哪崩。

整体流程对比

方式 问题 结果
直接在 syscall 处断 不知道 shellcode 地址 断点设不上/设错
在源码开头加 int3 无 停在开头,拿到真实 RIP,再精确断后面

类比

就像你要在一条你从没走过的路上设路标:

  • 直接想在"第 5 个路口"设路标 → 你连路在哪都不知道。
  • 先在起点设个路标 → 到了起点,你就能数着路口找到第 5 个。

一句话总结

在调试被攻击程序时,shellcode 最终加载到哪个地址是不确定的,所以很难直接在 syscall 那条指令处下断点。解决办法是:在 shellcode 源码开头放一个 int3(或入口断点),重新汇编。这样程序一进 shellcode 就停下,你就能拿到它的真实地址,再据此在后面精确下断点、用 si/ni 单步跟踪。

And then, let's look at—you know, our old version of this. Wow, very—really. Check. This is, like, flipping 5 in a row. All right. And so, of course, we no longer—oh. We didn't objcopy it out. Okay, so now you have the version with the debugger going on. We're really hitting it. GDB, you hit enter. Sorry about this. Of course, we've now overwritten that new line that we had put in there for the—gets. Now boom. Okay. There we go. We had hello. And we hit that breakpoint trap. Now, if you were running—yeah. Hello, in GDB, it would have dropped us into a debugger just like you saw earlier. And we can now debug the shell code. And you don't have to put in the beginning the int3. You can put it anywhere you want. Cool.

然后,我们看看——你知道,我们这个的旧版本。哇,非常——真的。看看。这就像,连续翻了 5 次。好。所以,当然,我们不再——

image-40
image-41

哦。我们没有把它 objcopy 出来。好,所以现在你有了带调试器的版本。我们真的在命中它。GDB,你按回车。抱歉。当然,我们现在已经覆盖了我们为——gets 放进去的那个换行符。现在 boom。好。我们来了。我们有 hello。然后我们命中了那个断点陷阱。现在,如果你在运行——对。在 GDB 里运行 hello,它会像你之前看到的那样把我们丢进一个调试器。我们现在可以调试 shellcode 了。而且你不必在开头放 int3。你可以把它放在任何你想放的地方。酷。

这张图和这段录音正好对应你上一个问题讲的 "在 shellcode 开头加 int3 打断点"。我们结合来解读。

命令拆解

( cat shellcode-raw; cat ) | ./hello

这个结构很关键,跟之前的 cat shellcode-raw | ./hello 不同:

部分 作用
cat shellcode-raw 先把 shellcode 的机器码输出到管道
; cat 接着把 stdin(你的键盘输入)也接到管道
(...) 用子 shell 把这两段输出合并成一条流
| ./hello 整个流作为 hello 的 stdin

为什么要加 ; cat?

  • 因为 shellcode 开头是 int3(0xCC),执行到它时进程会触发断点陷阱并暂停。
  • 这时 hello 还在运行,需要保持管道打开,否则 hello 的 stdin 立刻 EOF、可能提前退出。
  • ; cat 让管道保持打开,等你从键盘输入内容——这就给了你接管调试的时间。
  • 所以你看到程序停在 int3 后,cat 还在等你的输入,进程没有立即结束。

执行结果解读

!ello ♦♦UH♦H♦=♦
Trace/breakpoint trap

第一行:!ello ♦♦UH♦H♦=♦

  • hello 程序把读进来的 shellcode 字节当字符串打印了一部分(乱码),跟你之前单独运行 ./hello 时一样。
  • 说明 shellcode 确实被读进了 hello 的缓冲区。

第二行:Trace/breakpoint trap

这是核心。

  • int3 指令的机器码是 0xCC。
  • CPU 执行到 0xCC 时,会触发一个 breakpoint 异常。
  • 在没有 GDB 接管的情况下,内核把这个异常报告为 SIGTRAP(信号 5)。
  • Shell 通常把 SIGTRAP 描述成 "Trace/breakpoint trap"。
  • 所以这行输出 = shellcode 开头的 int3 被执行了!

这和你之前的实验形成了鲜明对比:

之前 现在
没加 int3 → Illegal instruction 加了 int3 → Trace/breakpoint trap
说明执行到非法字节崩了 说明正确到达了 shellcode 开头

对应录音内容

然后我们命中了那个断点陷阱。

  • 就是上面那个 Trace/breakpoint trap。
  • int3 被命中,证明控制权成功转移到了 shellcode 的起始位置。

现在,如果你在运行——对。在 GDB 里运行 hello,它会像你之前看到的那样把我们丢进一个调试器。

  • 意思是:如果你把 ./hello 放在 GDB 里运行,那么这个 SIGTRAP 会被 GDB 捕获。
  • GDB 会在 int3 处自动停下,把控制权交给你("把我们丢进一个调试器")。
  • 这正是你上一个问题里说的:停在开头 → 拿到真实 RIP → 精确下后面的断点。

我们现在可以调试 shellcode 了。

  • 因为程序现在准确地停在 shellcode 的第一条指令处(其实是 int3 之后的那条)。
  • 你可以:
    p/x $rip        # 拿到 shellcode 被加载的真实地址
    si / ni         # 单步跟踪
    break *0x...    # 在 syscall 等位置精确下断点
    
  • 这就解决了"不知道 shellcode 在哪、没法在 syscall 处下断点"的难题。

而且你不必在开头放 int3。你可以把它放在任何你想放的地方。酷。

  • int3 不限于开头。
  • 你可以把它插在 shellcode 的任意位置,比如:
    • 想检查 open 之后 rax 里的 fd → 在 open 的 syscall 之后插 int3。
    • 想检查 sendfile 的参数 → 在它之前插 int3。
  • 每插一个 int3,就是在那一点设了个"断点",程序跑到那就触发 SIGTRAP 停下。
  • 这样你就能分段调试 shellcode,一段一段验证。

整体流程

cat shellcode-raw | ./hello
        │
        │  shellcode 开头是 0xCC (int3)
        ▼
./hello 读入 shellcode,跳到它的地址执行
        │
        ▼
执行到 int3 → 触发 SIGTRAP → "Trace/breakpoint trap"
        │
        ├─ 不在 GDB 里:shell 报告信号,进程挂起/结束
        └─ 在 GDB 里:GDB 捕获,停在 int3 处,交给你调试

为什么这比"在 syscall 处硬下断点"强

回顾上一个问题的结论:

  • 直接想在 syscall 处断 → 不知道 shellcode 地址,下不了。
  • 在开头放 int3 → 程序一定停在已知的开头位置,你就能:
    1. 确认 shellcode 被执行了;
    2. 读出真实加载地址;
    3. 再据此在任意位置下断点。

这正是录音说的"我们现在可以调试 shellcode 了"的含义。

一句话总结

( cat shellcode-raw; cat ) | ./hello

这条命令把带 int3(0xCC)的 shellcode 喂给 hello,执行到 int3 时触发 SIGTRAP,shell 显示 "Trace/breakpoint trap"——这证明控制权成功到达了 shellcode 开头。在 GDB 里运行同样的命令,GDB 会捕获这个 SIGTRAP 并停在断点处,让你拿到真实地址、精确下断点、单步调试。int3 可以放在 shellcode 的任何位置,用来分段调试。

image-42

All right. What is next? How do you write shell code for other architectures? So far, we've been talking about x86-64, AMD64. Well, it turns out with this method of assembling shell code using GCC's essentially built-in tool, specifically GNU as assembler. Turns out that it's almost the same. You can install a cross compiler in your system. And you can use that cross compiler to compile shell code for MIPS. Assemble shell code for MIPS, which is super exciting. And you can also run that shell code with an emulator and test it out before deploying it on some real system. If you use QEMU as your emulator, QEMU has a -strace option. If QEMU straces QEMU itself—there's a lot of syscalls that QEMU is calling. But if you do QEMU -strace, QEMU itself will print out all the system calls that the shell code or whatever program you're emulating is calling. And if you pass it the -g option, it'll open up a port for remote debugging for your shell code, which is pretty awesome, too. So this set of tooling is really nice for shell code.

好。接下来是什么?你怎么为其他架构写 shellcode?到目前为止,我们一直在讲 x86-64、AMD64。嗯,事实证明,用这种使用 GCC 本质上内置工具、具体来说是 GNU as 汇编器来汇编 shellcode 的方法,结果几乎是一样的。你可以在你的系统里安装一个交叉编译器。然后你可以用那个交叉编译器为 MIPS 编译 shellcode。为 MIPS 汇编 shellcode,这超级令人兴奋。而且你还可以用模拟器运行那个 shellcode,在把它部署到某个真实系统上之前测试它。如果你用 QEMU 作为你的模拟器,QEMU 有一个 -strace 选项。如果 QEMU 对 QEMU 自己 strace——QEMU 会调用很多系统调用。但如果你做 QEMU -strace,QEMU 自己会打印出 shellcode 或你正在模拟的任何程序调用的所有系统调用。如果你传给它 -g 选项,它会为你的 shellcode 打开一个用于远程调试的端口,这也相当棒。所以这套工具对 shellcode 来说真的很不错。

Alright, let's say you want to practice. Right, which you do, of course. That's what pwn.college is all about. Practice makes perfect. Head over to pwn.college. Choose a level of the shell coding homework. Analyze that code and understand the constraints that your shell code must satisfy and the changes that will be made to your shell code and write shell code to bypass them. Depending on when in the year you're watching this video, the challenges might be down for about half a week as we prepare them for this—for this current class, if there is one. In that case, just hold tight. I'll announce when the challenges are available. And then you can go solve them. Good luck.

好,假设你想练习。对,你当然想。这就是 pwn.college 的意义所在。熟能生巧。去 pwn.college。选择一个 shellcoding 作业的关卡。分析那段代码,理解你的 shellcode 必须满足的约束,以及会对你的 shellcode 做出的修改,然后编写 shellcode 来绕过它们。取决于你在一年中的什么时候看这个视频,这些挑战可能会关闭大约半周,因为我们在为这个——为这个当前的班级(如果有的话)准备它们。在那种情况下,就稍安勿躁。我会在挑战可用时宣布。然后你就可以去解决它们了。祝你好运。

2. Common Challenges

Hello students. Welcome to the next video in the pwn.college shellcoding module. Today, we're going to be talking about common challenges that you might face in shellcoding. I hope you've seen the previous videos in this module, otherwise this might not make sense.

同学们好。欢迎来到 pwn.college shellcoding 模块的下一期视频。今天我们要讲的是你在 shellcoding 中可能遇到的常见挑战。希望你已经看过这个模块的前几期视频,否则这些内容可能不太好理解。

image-43

Let's get rolling. All right, we're going to start with something that is not necessarily a challenge that you'll run into, but more of a hurdle that a lot of people encounter. We're going to talk about** sizes of memory accesses**. If you recall from the Assembly Fundamentals lecture, you can access registers in different sizes. Well, those different sizes have an analog in memory access as well. You can access memory in different sizes as well.

那我们开始吧。好,我们首先要讲的东西,严格来说不算是你会遇到的一个“挑战”,更像是很多人都会碰到的一个障碍。我们要讲的是内存访问的大小。如果你还记得汇编基础讲座里讲的,你可以用不同的大小来访问寄存器。那么,这些不同的大小在内存访问中也有对应的东西。你也可以用不同的大小来访问内存。

Sometimes though, when you're writing, for example, just immediate values, constants, things might get a little trickier. Let's say you're writing number five. How many bytes is that? Technically speaking, you can fit the number five into one byte. But maybe you mean 0x0005 and you want eight bytes or something. Right, in these cases you can explicitly specify the size of the write to avoid any ambiguity. You can also specify it in the original case as well. Just keep in mind there are cases such as this in the bottom case—you have to specify on this, and the assembler, if you don't specify, will complain to you with kind of hard to understand errors.

但有时候,比如当你写入立即数、常量时,事情可能会稍微麻烦一点。假设你要写入数字 5。那是多少字节?从技术上讲,数字 5 可以放进一个字节里。但也许你指的是 0x0005,你想要八个字节之类的。对,在这些情况下,你可以显式指定写入的大小,以避免任何歧义。你同样也可以在原来那种情况下指定。只要记住,有些情况是这样的,在底部那种情况下,你必须在指定这个,如果你不指定,汇编器会给你报一些很难理解的错误。

image-44

Now, you know how to write memory with certain sizes. All right, let's move on to actual challenges. Talk about what you do in situations where you can inject shellcode, but there are certain values that cannot be present in your shellcode. This is actually a very common situation. Often, as we'll discuss later in future modules, strcpy is kind of a problem child sort of function that leads to buffer overflows. It's frequently the case that if you're injecting your shellcode with strcpy, then your shellcode cannot contain null bytes because strcpy will terminate and stop copying when it hits a null byte, because that's when strings end. Right, so that's one common problem.

现在你知道如何以特定大小写入内存了。好,我们进入真正的挑战。讲一讲当你能够注入 shellcode,但你的 shellcode 中不能包含某些特定值的时候,你该怎么办。这实际上是一个非常常见的情况。通常,正如我们以后在模块中会讨论的,strcpy 算是一个“问题儿童”函数,它会导致缓冲区溢出。经常发生的情况是,如果你用 strcpy 来注入你的 shellcode,那么你的 shellcode 就不能包含空字节,因为 strcpy 在遇到空字节时会终止并停止复制,因为字符串就是在那里结束的。对,所以这是一个常见问题。

Another common problem: newlines. If you're talking about something like scanf, gets, getline, fgets, these are also very common unsafe functions that lead to overwrites and so forth. And they terminate on a newline. And actually, scanf also terminates on a character—a tab, what have you. Right. Then there are other kind of special characters like 0x7F, that in certain protocols mean something special, and other ones. These are just kind of common ones.

另一个常见问题:换行符。如果你说的是 scanf、gets、getline、fgets 这类东西,它们也是非常常见的不安全函数,会导致覆盖等等。它们会在换行符处终止。实际上,scanf 还会在某个字符——制表符,或者别的什么——处终止。对。然后还有其他一些特殊字符,比如 0x7F,在某些协议中有特殊含义,还有其他的。这些只是比较常见的几种。

So you have to be ready to write shellcode that optimally doesn't include these characters. Because otherwise, your shellcode will get messed up in that transfer, basically, or in one of the read processes or something.** So how do you make sure that your shellcode doesn't contain these bad characters?** Well, there's a lot of ways to express the same thing in assembly, just like there is in C, just like there is in Python. There are many ways to do some specific thing you're trying to do.

所以你必须要准备好写出不包含这些字符的 shellcode。因为否则的话,你的 shellcode 在传输过程中——基本上就是在那些读取过程中或者别的什么——会被搞坏。那么你如何确保你的 shellcode 不包含这些坏字符呢?嗯,在汇编里,和 C 里、Python 里一样,有很多方式来表达同样的事情。你要做的那个特定的事情,有很多种做法。

image-45

Here are a couple of kind of bad to good examples that achieve the same thing. At the top here, we do mov RAX, 0. You want to write 0 into RAX. This has a ton of null bytes in it. Why? Because mov RAX, 0—it moves a 64-bit value into RAX. That 64-bit value is 0, but it's eight bytes of zeros. Right. Instead, we can replace it in this case with something like xor RAX, RAX. Which just clears out RAX, also sets it to zero, and is much shorter. There are actually even shorter forms of that. You can do xor EAX, EAX, which, if you remember the Assembly Fundamentals lecture, also zeros out the most significant bytes of RAX as well.

这里有几个从差到好的例子,它们达到同样的效果。最上面这里,我们做 mov RAX, 0。你想把 0 写入 RAX。这里面有大量的空字节。为什么?因为 mov RAX, 0——它把一个 64 位的值移到 RAX 里。那个 64 位的值是 0,但它是八个字节的零。对。取而代之,在这个例子里我们可以用像 xor RAX, RAX 这样的东西来替换它。它只是清空 RAX,也把它设为零,而且短得多。实际上还有更短的形式。你可以做 xor EAX, EAX,如果你还记得汇编基础讲座的话,它也会把 RAX 的高位字节清零。

But that's one example, right. Let's say you wanted to actually move some value to RAX. You still can, right. If you do mov RAX, 5, you have the same problem with just one of those null bytes. One of those—you know, many bytes of nulls is now a 5 instead of a null, and but the rest are still null bytes, so you still have this problem. Well, you can of course zero out RAX and then move 5 into AL. Right. Nice and simple there as well. You move five into AL so that RAX has just 0, 0, 0, 0, 0, 0, 5. Very simple.

但这是一个例子,对吧。假设你想真的把一个值移到 RAX 里。你还是可以的,对吧。如果你做 mov RAX, 5,你有同样的问题,只是其中一个空字节——那些很多字节的空值里,现在有一个是 5 而不是空值了,但其余的仍然是空字节,所以你还是有这个问题。嗯,你当然可以先把 RAX 清零,然后把 5 移到 AL 里。对。那里也一样简单。你把 5 移到 AL,这样 RAX 里就只有 0, 0, 0, 0, 0, 0, 5。非常简单。

All right. Let's say you want to move—another way to do things, if you are trying to avoid not zeros, but a newline, for example, and you want to move 10 into RAX. 10, also known in hexadecimal as 0x0A, is the newline character in ASCII. So this is a big problem if you have a scanf or some other analogous way of injecting your shellcode. It will terminate the read on that newline. And but that's okay, you can instead move 9 into it and then increment RAX, or you can xor RAX, RAX and then do inc 10 times, or—it's not—anything along those lines. There are a lot of different ways to do this that avoid having a literal 10 in your shellcode. A literal newline.

好。假设你想移入——另一种做法,如果你要避免的不是零,而是换行符,比如说,你想把 10 移到 RAX 里。10,十六进制里也写作 0x0A,是 ASCII 里的换行符。所以如果你用的是 scanf 或者其他类似的方式来注入你的 shellcode,这就是个大问题。它会在那个换行符处终止读取。但没关系,你可以改成先移入 9,然后递增 RAX,或者你可以xor RAX, RAX,然后做 10 次 inc,或者——不是——类似那样的东西。有很多不同的做法可以避免在你的 shellcode 里出现一个字面的 10。一个字面的换行符。

All right, final one, the fourth thing here. If we want to move the flag value into RBX—or /flag, a path to a string, and there it has null bytes in it. And this is, you know, it would not be reasonable to xor RAX, RAX and then have you gigabytes and gigabytes of inc RAX shellcode. Obviously, we can do something different. We can move just the word "flag" backwards—this is little endian—into EBX, then we shift EBX actually to the left by 8 bits, and then we move in the least significant byte that just cleared up the forward slash. And then we end up with backwards, this forward slash dot flag. Then when we write it to memory or whatever we do, you'll have /flag. Right, so we built the value, we can build it in many different ways, and we can use different instructions, even if the instruction itself is causing a problem for us.

好,最后一个,这里的第四件事。如果我们想把 flag 值移到 RBX 里——或者 /flag,一个字符串路径,而它里面有 null 字节。你知道,这不太合理,先 xor RAX, RAX 然后你有千兆字节的 inc RAX shellcode。显然,我们可以做点别的。我们可以只把 "flag" 这个词倒着移入 EBX——这是小端序——然后把 EBX 左移 8 位,然后我们把最低有效字节移进去,那个字节刚好是正斜杠。然后我们最后得到的是倒过来的,这个正斜杠点 flag。然后当我们把它写到内存里或者做别的什么时,你就会有 /flag。对,所以我们构建了这个值,我们可以用很多种方式构建它,我们可以用不同的指令,即使那条指令本身给我们造成了问题。

这段课程记录讲的是:当字符串里含 null 字节、不方便直接 mov 时,如何用"分步构建 + 移位"的方式把 /flag 拼进寄存器,从而避开坏字符。

背景问题:为什么不能直接 mov

想执行 open("/flag", ...),需要把字符串 /flag 送进内存。最直接的做法是:

mov rbx, 0x67616c662f     ; "/flag" 的字节
push rbx

但有两个问题:

问题 1:/flag 本身不含 null,但 mov 指令会引入 null

mov rbx, 0x67616c662f 这个立即数只有 5 个有效字节,前面需要补 0x00 才能填满 8 字节寄存器:

mov rbx, 0x00000067616c662f

这条指令的机器码里就包含了一堆 0x00 字节。而 0x00 是最经典的 bad character(坏字符)——如果注入路径遇到 0x00 会截断字符串,shellcode 就废了。

问题 2:如果字符串本身含 null,更不可能直接 mov

课程开头说:

如果我们想把 flag 值移到 RBX 里——或者 /flag,一个字符串路径,而它里面有 null 字节。

意思是:字符串里的 null 字节 + 指令编码引入的 null 字节,都会成为坏字符。

常见的"笨办法":xor + 一堆 inc

课程调侃说:

你知道,这不太合理,先 xor RAX, RAX 然后你有千兆字节的 inc RAX shellcode。

意思是:如果不能用 mov 带立即数(因为有 null),有人会想:

xor rax, rax        ; rax = 0
inc rax             ; rax = 1
inc rax             ; rax = 2
...                 ; 一直 inc 到目标值

这样每条指令都不含 null,但要几千条 inc,shellcode 长得离谱("千兆字节"是夸张)。不实用。

课程的解法:分步移位构建

原文:

我们可以只把 "flag" 这个词倒着移入 EBX——这是小端序——然后把 EBX 左移 8 位,然后我们把最低有效字节移进去,那个字节刚好是正斜杠。然后我们最后得到的是倒过来的,这个正斜杠点 flag。

步骤拆解

目标是得到 /flag 这个字符串在内存里(小端序下,寄存器里应存 0x67616c662f,即字节顺序 2f 66 6c 61 67 = / f l a g)。

方法:先放 flag,再移位腾出空间,最后塞入 /。

第 1 步:把 flag 放进 EBX

flag 的 ASCII:66 6c 61 67(f l a g)。

按小端序放进 32 位寄存器 EBX:

mov ebx, 0x67616c66     ; 字节顺序: 66 6c 61 67 = "flag"

此时 ebx = 0x67616c66。

第 2 步:左移 8 位,腾出最低字节

shl rbx, 8              ; rbx << 8

rbx 从 0x0000000067616c66 变成 0x00000067616c6600。

  • 字节顺序变成:00 66 6c 61 67(低位空出一个字节)。
  • 意思是"把 flag 往高位挪一格,低位空出来给 /"。

第 3 步:把 / 移进最低有效字节

/ 的 ASCII 是 0x2f。

mov bl, 0x2f            ; 或者用 add/or 等方式把最低字节设为 0x2f

rbx 变成 0x00000067616c662f,字节顺序:2f 66 6c 61 67 = / f l a g。

结果

寄存器 rbx 里现在就是 0x2f666c6167 → 小端序下写进内存就是字符串 /flag。

push rbx                ; 把 "/flag" 压栈,得到一个以 /flag 开头的字符串
mov rdi, rsp            ; rdi 指向 "/flag"

为什么这样能避开坏字符

  • 整个构建过程中,没有任何指令的机器码需要包含 0x00 立即数。
  • 用的都是小立即数(0x67616c66、0x2f)和移位指令,编码里 null 少得多。
  • 所以最终 shellcode 可以不含 null 字节,能通过字符串注入路径。

"我们可以用很多种方式构建它"

原文最后:

对,所以我们构建了这个值,我们可以用很多种方式构建它,我们可以用不同的指令,即使那条指令本身给我们造成了问题。

意思是:

  • 构建字符串不止一种方法。
  • 可以用 mov + shl + mov bl,也可以用 xor + add + or 等组合。
  • 关键是选那些机器码里不产生坏字符的指令编码。
  • 如果某条指令的编码"本身给你造成问题"(比如引入了 null),就换一条等效的指令实现同样效果。

对比总结

方法 问题
mov rbx, 0x00000067616c662f 机器码含 0x00(坏字符)
xor rax,rax + 几千个 inc 指令太长,不实用
mov ebx, "flag" + shl rbx,8 + mov bl, "/" ✅ 短,且避开 null

类比

把寄存器当成一排格子:

  1. 先放 f l a g 占 4 格。
  2. 整体左移一格,最右边空出一格。
  3. 把 / 填进最右那格。
  4. 结果就是 / f l a g。

这样不用一次性写入含 null 的大立即数,而是小步构建,让每条指令的编码都不含坏字符。

一句话总结

这段在讲:直接把 /flag 用 mov 送进寄存器会引入 0x00(坏字符),用 inc 堆又太长。实用做法是分步构建——先把 flag 放进 ebx,左移 8 位腾出最低字节,再把 /(0x2f)塞进最低字节,得到 /flag 的小端序表示。关键不是"只能这么做",而是选那些机器码不含坏字符的指令来拼出目标值。

image-46

For example, if you can't use xor, there are other instructions that you can use that do similar things. Right. There are tools that'll do this automatically for you. I would recommend, especially as you learn shellcode and especially in this homework, don't use them. They don't always work. When they don't work, it's not clear if it's your shellcode that's incorrect or these tools that are trying to make your shellcode pass certain constraints are incorrect. You end up spending insane amount of time debugging. I wouldn't recommend it. Just write your shellcode and figure out how to bypass these—whatever constraints that you have, whatever bytes you can't use and so forth—by hand. All right.

比如说,如果你不能用 xor,还有其他指令你可以用,做类似的事情。对。有些工具会自动帮你做这些。我建议,尤其是当你在学习 shellcode 的时候,尤其是在这个作业里,不要用它们。它们不总是管用。当它们不管用的时候,你不太清楚是你的 shellcode 不对,还是这些试图让你的 shellcode 通过某些约束的工具不对。你最后会花大量的时间调试。我不建议这样。就自己写你的 shellcode,然后想办法绕过这些——你有的任何约束,你不能用的任何字节等等——手动地绕过。好。

Rolling on. You might run into a situation where certain instructions are simply forbidden, or where there are constraints where it's very hard to get around them and still have functional shellcode. Remember in a von Neumann architecture, code and data are the same thing. And oftentimes, by the time you're writing shellcode, you have a region that you were able to write to and then are able to execute. So you have writable code. This is important. You can do very clever stuff.

继续。你可能会遇到某些指令被直接禁止的情况,或者某些约束让你很难绕过它们同时还能有可用的 shellcode。记住,在冯·诺依曼架构里,代码和数据是同一回事。而且通常,当你写 shellcode 的时候,你有一个区域,你能够写入,然后能够执行。所以你有可写的代码。这很重要。你可以做非常聪明的事情

image-47

So let's say for whatever reason I forbade int3, the debug opcode, in shellcode or whatever—the vulnerable program wouldn't accept that, or there was a constraint applied somewhere along the line that would negate it. And you really need to use int3. int3 in assembly, in binary, is 0xCC, the byte 0xCC in hexadecimal. So what you can do is write a 0xCB and increment it. And that's exactly what this little snippet does. It increments the instruction pointer—no, by the instruction pointer, which is going to be the next instruction to execute—increments it, and then it'll execute.

假设出于某种原因,我禁止了 int3,那个调试操作码,在 shellcode 里或者别的什么——有漏洞的程序不接受它,或者在某个环节有一个约束会把它抵消掉。而你又真的需要使用 int3。int3 在汇编里,在二进制里,是 0xCC,十六进制的字节 0xCC。所以你可以做的是写入一个 0xCB,然后递增它。这正是这个小片段做的事情。它递增指令指针——不,通过指令指针,也就是下一条要执行的指令——递增它,然后它就会执行。

image-48

You'll have to make sure when testing this that—if you're using the shellcode running and testing method that we talked about earlier in this module, you have to make sure that you compile your shellcode with the .text segment being writable. That's important. Otherwise, by default, it is not—it's read-only. And then it's a pain in the butt. So you can do this right here, this dash capital W lowercase L, comma, dash capital N.

你在测试这个的时候必须确保——如果你用的是我们在这个模块前面讲过的 shellcode 运行和测试方法,你必须确保在编译你的 shellcode 时让 .text 段是可写的。这很重要。否则默认情况下它不可写——它是只读的。那就会很麻烦。所以你可以在这里做这个,这个 -Wl,-N。

Dash capital W capital L tells GCC to pass this option to the linker. And this option does a number of things that has a side effect—makes a writable text segment. And then you can do this sort of thing. Of course, at execution time, usually—frequently, your shellcode is writable, but not always.

-Wl 告诉 GCC 把这个选项传给链接器。这个选项做了很多事情,有一个副作用——让 text 段可写。然后你就可以做这种事了。当然,在执行的时候,通常——经常,你的 shellcode 是可写的,但不总是

image-49

All right. What else can you do? If constraints in your shellcode are so hard that you just can't get a system call out, or that you can't achieve whatever goal you want your shellcode to achieve, you can always—load more shellcode. Not always, but frequently, you can load more shellcode. Right. This is called multi-stage shellcode. You have stage one that reads in stage two.

好。你还能做什么?如果你的 shellcode 里的约束太苛刻,以至于你根本没法发出一个系统调用,或者你无法实现你希望 shellcode 达成的任何目标,你总是可以——加载更多 shellcode。不总是可以,但经常可以,你可以加载更多 shellcode。对。这叫做多阶段 shellcode。你有第一阶段,它读取第二阶段。

So let's say that you had stage one and you could just barely create a stage one shellcode that could call read from standard input into the buffer pointed to by the instruction pointer, which would be the next instruction to execute, of 1000 bytes. If you manage to execute that and your shellcode is writable, then you can—you would read in more shellcode directly in front of the instruction pointer and have it execute. That's a pretty good way of bypassing complex filters. And in this assignment, you will likely have to do that.

假设你有了第一阶段,你勉强能写出一个第一阶段 shellcode,它可以调用 read,从标准输入读到指令指针指向的缓冲区,也就是下一条要执行的指令,读 1000 个字节。如果你成功执行了那个,而且你的 shellcode 是可写的,那么你就可以——你会把更多 shellcode 直接读到指令指针前面,并让它执行。这是绕过复杂过滤器的好方法。在这个作业里,你很可能需要这么做

Just as a quick note, this sort of thing is kind of hard to do—harder to do on different architectures. On x86 32-bit it's a pain in the butt. I mean, it's doable, but it has its caveats. On AMD64, this is actually super easy. You can load effective address of the memory pointed to by the instruction pointer into RAX, for example. Super easy. I guess in this case, we'd want it to be loaded into RSI. All right.

快速提一下,这种事情其实挺难做的——在不同的架构上更难做。在 x86 32 位上,那是个麻烦事。我的意思是,可以做,但有它的注意事项。在 AMD64 上,这实际上超级简单。你可以把指令指针指向的内存的加载有效地址放进 RAX,比如说。超级简单。我想在这个例子里,我们想让它加载到 RSI 里。好。

Cool, so that is how you would bypass—let's say a really hard filter that makes it very difficult to do reasonable things. So instead, you can try to just write smaller shellcode that reads in your bigger, real shellcode.

酷,这就是你绕过——假设一个非常严苛的、让你很难做合理事情的过滤器——的方法。所以你可以改成写一个更小的 shellcode,它读入你更大的、真正的 shellcode。好。

image-50

All right. Finally, just a note on shellcode mangling. Your shellcode might be pretty messed up and unrecognizable. A challenge might sort your shellcode. Right, this is something that generally speaking will likely only happen in shellcoding challenges, but I've seen shellcode that had to be written in a way that the data you provided is first uncompressed, and then that results in shellcode execution. Right. So the shellcode has to—not really survive on compression, but your data when uncompressed has to be your shellcode. It might be encrypted or decrypted with some key, a lot of different things that could happen.

最后,简单说一下 shellcode 变形。你的 shellcode 可能会被搞得很乱、面目全非。一个挑战可能会对你的 shellcode 进行排序之类的。对,一般来说,这很可能只会出现在 shellcoding 挑战里,但我见过一些 shellcode 必须写成这样:你提供的数据先被解压,然后才导致 shellcode 执行。对。所以 shellcode 必须——不完全是经受住压缩,而是你的数据在被解压后必须就是你的 shellcode。它可能被加密,或者用某个密钥解密,可能发生很多不同的事情。

My advice to you is to start backwards. Think about what do I want my shellcode to look like when it executes, and then think, okay, now what do I have to provide to the program to make that happen? Just step by step, right. If the program uncompresses your shellcode and you want your shellcode to execute after being uncompressed, you start with your shellcode and you compress it. Right. That's fairly straightforward, but it's a small concept that is sometimes tricky to wrap your head around.

我的建议是倒着来。想想我希望我的 shellcode 执行的时候长什么样,然后想,好,现在我需要给程序提供什么才能让那个发生?一步一步来,对。如果程序解压你的 shellcode,而你想让你的 shellcode 在被解压后执行,你就从你的 shellcode 开始,然后压缩它。对。这相当直接,但这是一个有时候很难想通的小概念。

image-51

All right. Final thing I'll mention: there are times when your shellcode cannot directly talk to you. All the file descriptors are closed, or your shellcode is actually running on some embedded device in a power plant somewhere because you're launching some crazy attack that you shouldn't be. How else can you communicate? The flag, right. If you don't have a direct connection back to yourself, there are a lot of different ways. They're very situation dependent, of course, but the general idea is if you can communicate one bit of information, then you can communicate just one bit. And that one bit could be the program hung or didn't hang. The program crashed or didn't crash. Or any other large amount of things that you could convey from your shellcode back to your observer.

好。最后我要提一件事:有时候你的 shellcode 没法直接跟你通信。所有的文件描述符都被关闭了,或者你的 shellcode 实际上跑在某个发电厂里的嵌入式设备上,因为你正在发动某种你不该发动的疯狂攻击。那你怎么还能通信?flag,对。如果你没有直接连回你自己的连接,有很多不同的方式。当然,它们非常取决于具体情况,但总体思路是:如果你能通信一个比特的信息,那么你就能通信那一个比特。那一个比特可以是程序挂起了或者没挂起。程序崩溃了或者没崩溃。或者任何其他大量的事情,你都可以从你的 shellcode 传回给你的观察者。

Of course, in very remote scenarios, it can be stuff like DNS lookups, it can be a lot of different things. But that's out of the scope of this module, but you will need to understand this concept to pass all of the challenges that we're launching.

当然,在非常远程的场景里,它可以是 DNS 查询之类的东西,可以是很多不同的事情。但那超出了这个模块的范围,但你需要理解这个概念才能通过我们将要发布的所有挑战。

image-52

All right. Useful tools for shellcode writing and debugging. I mentioned Radare2 already in the assembly fundamentals, and I sent a link—or I had a link to the AMD64 opcode listing, that's very useful to see what instructions you can use given certain constraints. Add a couple other ones. Pwntools is a very nice library, not just for writing the exploit and shellcode, but also for launching and interacting with the vulnerable program. Right, it's very good for that. I highly recommend using it.

好。写和调试 shellcode 的有用工具。我在汇编基础里已经提到了 Radare2,我还发了一个链接——或者说我有一个链接,指向 AMD64 操作码列表,这在某些约束下查看你可以用哪些指令时非常有用。再加几个。Pwntools 是一个非常好的库,不仅用于写 exploit 和 shellcode,也用于启动和与有漏洞的程序交互。对,它在这方面非常好。我强烈推荐使用它。

Pwntools also has shellcode that it can generate for you. I would recommend not using that for this assignment. It'll work for the first couple of levels, but it's going to do you a big disservice afterwards when you kind of hit a wall on levels it can no longer solve.

Pwntools 也有可以为你生成的 shellcode。我建议在这个作业里不要用那个。它在前几个关卡里能管用,但之后当你在它无法解决的关卡上撞墙时,它会给你帮倒忙。

Finally, for debugging your source—your shellcode, obviously you will learn to love GDB. GDB is a little easier to love if you customize it just a little bit. There are several plugins that make GDB better. I highly recommend one of these. I'm most familiar with Pwndbg at this point. But PEDA has its adherents and Pwndbg has its adherents. Pick one, and install it to make GDB much, much simpler, much easier to use. You're going to actually, even if you want, install it inside that container for the challenge problems. Set it up. And then it'll stay around because your home directory is persistent.

最后,关于调试你的源代码——你的 shellcode,显然你会学会爱上 GDB。如果你稍微定制一下 GDB,它会更容易让人喜欢。有几个插件能让 GDB 变得更好。我强烈推荐其中一个。我现在最熟悉的是 Pwndbg。但 PEDA 有它的拥趸,Pwndbg 也有它的拥趸。选一个,然后安装它,让 GDB 变得简单得多、好用得多。你甚至可以把它安装在那个用于挑战问题的容器里。设置好。然后它会一直保留,因为你的 home 目录是持久的。

Awesome, that is all I have for shellcode challenges. Good luck on those assignments.

太棒了,这就是我关于 shellcode 挑战的全部内容。祝你在那些作业里好运。

3. Data Execution Prevention

Hello hackers, welcome to another video in the pwn.college shellcoding module. Today we'll be talking about Data Execution Prevention, just very briefly.

黑客们,欢迎来到 pwn.college shellcoding 模块的另一期视频。今天我们要讲的是数据执行保护(Data Execution Prevention),就非常简短地讲一下。

image-56

If you remember the three Johns from our Computer Architecture Fundamentals, the three Johns gave us what is now called the von Neumann architecture.

如果你还记得计算机体系结构基础里的那三位 John,这三位 John 给了我们现在所谓的冯·诺依曼架构。

image-53

They proposed this architecture that basically treats code and data in the same way. They're all stored in memory, they're all read into your CPU cache and so forth and so on. And this causes problems, as we have seen throughout this module. This allows us to inject data as if it were code and have it executed, and chaos ensues. It brought down the internet in 1988, you know, as an exploit that involved shellcode, and it is—bye—cause you to have to tackle a lot of challenge problems that be prepared for you. It's a pretty interesting side effect of this design decision.

他们提出的这个架构基本上把代码和数据用同样的方式对待。它们都存储在内存里,它们都被读进你的 CPU 缓存等等等等。而这会导致问题,正如我们在这个模块里一路看到的那样。这允许我们把数据当作代码一样注入,并让它执行,然后一片混乱。它在 1988 年搞瘫了互联网,你知道,作为一个涉及 shellcode 的利用,而且它会——bye——导致你不得不去解决一堆为你准备好的挑战题。这是这个设计决策的一个相当有趣的副作用。

image-54

So sometime relatively recently, as in the last, I don't know, maybe almost two decades, computer architecture designers started thinking, well, what if we treat data and code slightly differently? Just a little bit. And Data Execution Prevention was born.

所以在相对最近的某个时候,大概在过去——我不知道——差不多二十年里,计算机体系结构设计者开始想,如果我们稍微不一样地对待数据和代码会怎样?就一点点。于是数据执行保护诞生了。

image-55

Modern architectures support permissions on data. These permissions can be—can we read this data? Can we write this data? And can we execute this data? So these constants, PROT_READ, PROT_WRITE, PROT_EXEC, are from, you know, the mmap and mprotect system calls. You can actually use them to define the permissions of memory that you are mapping or changing the permissions of.

现代架构支持对数据的权限。这些权限可以是——我们能读这个数据吗?我们能写这个数据吗?我们能执行这个数据吗?所以这些常量,PROT_READ、PROT_WRITE、PROT_EXEC,来自你知道的 mmap 和 mprotect 系统调用。你实际上可以用它们来定义你正在映射的内存的权限,或者改变内存的权限。

The intuition behind this was, look, normally all the data in that text segment in your ELF—that's what needs to be executable. No one needs to execute—not knowing, but usually you don't need to execute the stack. Sometimes you do, but that's rare, and in those cases you'll deal with it yourself. By default, let's make sure nothing on the stack can be executed, nothing in the program code can be changed. Everyone will be happy.

这背后的直觉是,你看,通常你的 ELF 里 text 段中的所有数据——那才是需要可执行的东西。没有人需要执行——不知道,但通常你不需要执行栈。有时候你需要,但那很少见,在那些情况下你自己会处理。默认情况下,让我们确保栈上的任何东西都不能被执行,程序代码里的任何东西都不能被修改。大家都会开心。

In different systems, this is called different things. On Windows, it tends to be called DEP, Data Execution Protection. On Linux systems, it is frequently referred to as NX, the No-eXecute bit. It has been called W^X, or W XOR X. This is very difficult to write because it involves an XOR. Sometimes written as W plus X, that conveys the idea that something should never be executable and writable at the same time. Known by a lot of different things, but generally speaking, Data Execution Protection prevents your shellcode—any data that you inject—from being able to execute.

在不同的系统里,这个东西有不同的叫法。在 Windows 上,它往往叫 DEP,Data Execution Protection。在 Linux 系统上,它经常被称为 NX,也就是 No-eXecute 位。它也被称为 W^X,或者 W XOR X。这个很难写,因为它涉及一个异或。有时候写成 W+X,传达的意思是某个东西永远不应该同时是可执行和可写的。它有很多不同的叫法,但一般来说,数据执行保护会阻止你的 shellcode——你注入的任何数据——被执行

image-57

So is that the end of shellcode? Well, it might not be that, but it definitely hurts. Shellcode in some ways is now a little bit of an ancient art. Just by—mysterious wise hackers, it's still very, very much around in embedded devices. There's still a lot of embedded devices that don't have good Data Execution Protection. And it is around in kind of the general purpose computing landscape as well, in a number of different ways that we'll discuss in this lecture.

那么 shellcode 就此终结了吗?嗯,可能不至于,但它肯定受到了打击。shellcode 在某些方面现在有点像一门古老的艺术。就靠——神秘而智慧的 hacker 们,它在嵌入式设备里仍然非常非常普遍。还有很多嵌入式设备没有良好的数据执行保护。它在通用计算领域里也仍然存在,以许多不同的方式,我们这节课会讨论。

image-58

So let's talk about kind of the remaining shellcode injection methods. Thing number one, the kind of most straightforward way to talk about, is well, you can use a system call—a system call called mprotect to actually change the permissions of memory to make it executable and writable. Right. This is kind of a chicken and egg problem because usually in order to do that, you already have to have some high level of control over a program.

那我们来讲讲剩下的 shellcode 注入方法。第一件事,最容易讲的一种方式是,你可以用一个系统调用——一个叫 mprotect 的系统调用——来实际改变内存的权限,让它变得可执行和可写。对。这有点像先有鸡还是先有蛋的问题,因为通常为了做到这一点,你已经必须对程序有某种高水平的控制。

A very common way in challenges to do it is through return-oriented programming, which we will talk about in a future module. But you basically create a small return-oriented program to mprotect a piece of memory and then you run your shellcode out of that. Other cases are very situational. It really depends on what is the program trying to do, what is the vulnerability and so forth. It is not impossible to have this happen, especially as a result of a race condition or something along these lines, but it's a situational scenario.

在挑战中一个非常常见的做法是通过 return-oriented programming,我们会在以后的模块里讲。但你基本上是创建一个小型的 return-oriented program 来 mprotect 一块内存,然后你从那里运行你的 shellcode。其他情况则非常取决于具体情境。它真的取决于程序试图做什么、漏洞是什么等等。让这种情况发生并非不可能,尤其是作为竞争条件或类似东西的结果,但这是一个取决于情境的场景。

image-59

A large class of scenarios where shellcode does still exist involve just-in-time compilers. Just-in-time compilers were created to allow you to run JavaScript very, very, very quickly. The idea is you take an interpreted language—it doesn't just have to be JavaScript or, you know, Java bytecode or something along these lines—and at runtime, if you feel that it would help you run things faster, you compile that a little bit of code at a time, just in time, to run it just now.

shellcode 仍然存在的一大类场景涉及即时编译器(just-in-time compilers)。即时编译器被创造出来,是为了让你能非常非常非常快地运行 JavaScript。思路是,你拿一门解释型语言——不一定非得是 JavaScript,或者你知道,Java 字节码或者类似的东西——然后在运行时,如果你觉得这能帮你更快地运行东西,你就一次编译一点点代码,即时地,就现在运行它。

Compilation is a little bit outside of the scope of this course in general. Look it up if you're interested, there's, you know, a lot of resources on the internet to learn about it. But the general idea is you're generating code, you're generating binary code that needs to be able to run. So that binary code that is generated needs to be able to be written somewhere and then executed from there. Right, the safest thing to do would be to memory map a place just for it, to write the code to, then mprotect that place so that that memory location is no longer writable, then execute that code.

编译一般来说有点超出这门课的范围。如果你感兴趣就去查一查,你知道,互联网上有很多资源可以学。但总的思路是,你在生成代码,你在生成需要能够运行的二进制代码。所以生成的那段二进制代码需要能够被写到某个地方,然后从那里执行。对,最安全的做法是专门为它映射一块内存,把代码写进去,然后 mprotect 那个地方,让那个内存位置不再可写,然后执行那段代码。

If you need to update it—just-in-time compilers are usually incremental, so they'll have to update code over time—you'll need to once again mprotect it to be writable, and then once again mprotect it to be executable only. This is the safe thing to do. Right. But system calls are slow. So this is also an extremely slow thing to do. In fact, an approach like this likely is going to have enormous overhead, and the point of just-in-time compilation should be very fast, to speed things up. And slow and safe tends to lose out over fast. As a result, writable executable pages are common.

如果你需要更新它——即时编译器通常是增量的,所以它们得随着时间更新代码——你就需要再一次把它 mprotect 成可写的,然后再一次把它 mprotect 成仅可执行。这是安全的做法。对。但系统调用很慢。所以这也是一件极其慢的事情。事实上,像这样的方法很可能会带来巨大的开销,而即时编译的意义应该在于非常快,为了加速。而慢而安全往往会输给快。结果是,可写可执行页很常见。

And it gets a little worse. Even if your program uses a library that uses writable executable pages, then those pages are in your memory space, right, so you're vulnerable too. But let me show you just how common these are. Let's see if I can do this without spewing secrets all over the place. I talked about the /proc file system before. Here in /proc, we have directories for all the processes running on the machine.

而且情况还会更糟一点。即使你的程序使用了一个使用可写可执行页的库,那么那些页也在你的内存空间里,对吧,所以你也是脆弱的。但让我给你看看这些东西到底有多常见。看看我能不能做这个而不把机密到处乱喷。我之前讲过 /proc 文件系统。在 /proc 这里,我们有机器上运行的所有进程的目录。

image-63

So, let's just grab—through all of these processes. There's a while—for each of these process IDs, self is just a symlink to my current—this would be a symlink to the process ID, which is pretty cool. All super dynamic and everything. All right. If we are—grep all of these process maps for the word—for the permissions rwx. Right. Ideally, we wouldn't see any. But we see a lot, see a lot of files that match these permissions, and we can use our friend parallel to run more commands here.

所以,我们只要抓取——遍历所有这些进程。有一个 while——对于这些进程 ID 中的每一个,self 只是一个指向我当前——这应该是一个指向进程 ID 的符号链接,挺酷的。全都超级动态什么的。好。如果我们——grep 所有这些进程映射,找这个词——找权限 rwx。对。理想情况下,我们不应该看到任何。但我们看到很多,看到很多匹配这些(rwx)权限的文件,我们可以用我们的朋友 parallel 在这里运行更多命令。

这里说的是 /proc/self:

/proc/self 是一个符号链接,指向当前正在访问 /proc 的那个进程的 PID 目录。

比如你用自己的 shell 访问 /proc/self,它会指向你 shell 的 PID;换成另一个进程访问,它就指向那个进程。

所以它"超级动态"(super dynamic)——同一个路径,不同进程访问会指向不同目录。

每个进程的内存映射在 /proc/pid/maps 里

image-64

If we—let's see if this works. Okay. There's just take the base name—all of these programs have a page mapped in memory that is writable and executable. And there are some heavy hitters in here. X, my freaking graphical interface. We have Zoom. Zoom has a writable and executable page. If there is shellcode injection in Zoom, now there's a place for that shellcode to execute. Firefox—it has a just-in-time compiler, it's possible it's there. I'm actually curious, I didn't think it was. Obviously this is a little off script. Where's the W^X? I don't see it.

如果我们——看看这个能不能行。好。只要取基本名——所有这些程序在内存里都映射了一个可写且可执行的页。这里有一些重量级选手。X,那个图形界面。我们有 Zoom。Zoom 有一个可写可执行的页。如果 Zoom 里有 shellcode 注入,那现在就有了一个让那个 shellcode 执行的地方。Firefox——它有一个即时编译器,它可能在那里。我其实很好奇,我没想到它在。显然这有点脱离剧本了。W^X 在哪?我没看到。

image-65

Well, actually, let's be smart about this. An N video library, so here, I mean, for some reason, this library has a writable and executable page mapped. And as a result—bye—now my Firefox does as well. Who else was on this list? Some known components, Discord and Slack. Open Broadcast System. And I bet this is because it's Electron apps. Anyways. A lot of things have writable and executable code, even on my system. Even my system.

嗯,实际上,我们聪明一点。一个 N 开头的视频库,所以这里,我的意思是,出于某种原因,这个库映射了一个可写且可执行的页。结果是——bye——现在我的 Firefox 也是了。这个列表上还有谁?一些已知的组件,Discord 和 Slack。Open Broadcast System。我打赌这是因为它是 Electron 应用。总之。很多东西都有可写可执行的代码,即使在我的系统上。即使在我的系统上。

image-66

Alright. So obviously this could still allow some shellcode action. Let's move on. What if the JIT was safe? Right, well, you can still attack it given certain scenarios. There's a technique called JIT spraying. So imagine a just-in-time compiler would need to compile all of your code—all of your JavaScript code, for example—into—if your JavaScript code has constants that happen to have the values of valid x86 assembly, they might end up in memory that is executable. Now, there are mitigations that have been developed for this as well, but the general concepts still stand.

好。所以显然这仍然可能允许一些 shellcode 行动。我们继续。如果 JIT 是安全的呢?对,嗯,在某些场景下你仍然可以攻击它。有一种技术叫 JIT spraying。所以想象一个即时编译器需要把你所有的代码——比如你所有的 JavaScript 代码——编译成——如果你的 JavaScript 代码里有恰好是合法 x86 汇编值的常量,它们可能会最终出现在可执行的内存里。现在,针对这个也已经开发出了一些缓解措施,但总体概念仍然成立。

image-67

You could end up—because you might be a malicious website that can run its own JavaScript in a browser, you might be able to cause things to be just-in-time compiled and placed into memory in ways that you more or less control, and then by triggering a vulnerability to redirect code to there, you're running shellcode. This also happens, by the way. All right. And this isn't just a theoretical problem, right, JIT is everywhere. It's in your browser, it's in a lot of software that you're using, it's in your JavaScript interpreter for sure, in your Lua interpreter, in your Python interpreter if you're using PyPy, etcetera.

你最后可能——因为你可能是一个恶意网站,能在浏览器里运行自己的 JavaScript,你可能能让东西被即时编译,并以你或多或少控制的方式放进内存,然后通过触发一个漏洞把代码重定向到那里,你就在运行 shellcode 了。顺便说一下,这种情况也会发生。好。而且这不仅仅是理论问题,对吧,JIT 无处不在。它在你的浏览器里,在你正在使用的很多软件里,肯定在你的 JavaScript 解释器里,在你的 Lua 解释器里,在你的 Python 解释器里,如果你用的是 PyPy 的话,等等。

image-68

A very useful thing, but it reopens this attack vector. Another mitigation, just a preview because we're going to move on to this in the next module—what happens if we say, okay, fine, the attacker is going to run their shellcode somehow. But can we prevent that shellcode from doing a lot of damage? Stay tuned.

一个非常有用的东西,但它重新打开了这个攻击向量。另一个缓解措施,只是预告一下,因为我们下一个模块要讲这个——如果我们说,好吧,行,攻击者总会以某种方式运行他们的 shellcode。但我们能不能阻止那个 shellcode 造成很大的破坏?敬请期待。

babyshell练习:https://pwn.college/cse466-f2023/shellcode-injection

下载:git clone https://github.com/pwncollege/challenges-old2.git

posted on 2026-09-24 22:42  d0ubleU0x00  阅读(12)  评论(0)    收藏  举报

导航