万般含义在皮鞋?
本周末,写个小文,就当开胃小食吧,和TCP/IP无关,也没有皮鞋。
Linix系统中可执行文件是ELF格式,Windows系统可执行文件是PE格式,ELF和PE是完全不同的文件格式互不兼容,所以一个可执行文件不能在两个系统之间互相交换执行,然而两个程序均跑在Intel/AMD的处理器上,执行的都是一模一样的指令…
如果我在文件里就写一个0x90机器码,那么很遗憾,两个系统均无法执行!
这就是TMD门槛,将事情变成不再纯粹的门槛!
现在编程已经没有以前好玩了,现在的编程语言太丰富,现在的编程语言太繁琐,现在的编程环境太复杂,而这一切,都是旨在 最大程度的隐藏底层细节,让程序员将精力集中在关注业务逻辑上!
上面这段话讲得非常矛盾! 看你如何理解。
如果你把编程当成谋生之道,那么当然需要 集中精力于经理分配的任务,而不是纠结什么语言细节,底层原理。 反之,如果你只是觉得好玩,希望探究底层的秘密,那么 肯定不希望去处理什么业务逻辑,业务是需求驱动的,我只是玩玩而已! 然而现有的编程环境已经不适合去探究系统的秘密了。
你想让一段哪怕再简单的指令跑起来,额外的工作量已经超过了写这段指令本身,你要有编译器,要遵守该编程语言的语法规范,换句话说,你要花精力去搭建环境,以及调试程序本身。
现在的编程环境已经全部为业务程序员而优化了!和业务逻辑越来越关联的语言,库,中间件,网络协议…即便是一个写Apache,Nginx模块的 底层C/C++程序员 ,也至少要依赖操作系统,glibc这些,至少,你要 首先编译出一个Linux下可运行的ELF文件 吧。
举个例子,你能在Linux平台写一个非ELF的直接可执行的二进制文件吗? 最简单的,我们知道,0x90这个机器码代表 nop ,即 什么都不做 ,我能将0x90这个指令写入一个如下文件nop.exe:
[root@localhost ~]# hexdump nop.exe
0000000 0090
然后,我执行nop.exe,然后 让CPU什么也不做 吗?
不能!!看看吧,一个程序员想 让CPU什么也不做 都不行!而且这个程序员写的还是超级牛逼的机器语言0x90指令!如此牛逼的底层技术,竟然跑不起来!
好吧!很惭愧!但是想让nop跑起来,系统程序员还是有办法的,写汇编呗。
.global _start
_start:
nop
然后 按照标准流程 编译之:
[root@localhost ~]# as --64 -o nop.o nop.s
[root@localhost ~]# ld -melf_x86_64 -o nop.exe nop.o
然后,看看编译好的nop.exe之细节:
[root@localhost ~]# file ./nop.exe
./nop.exe: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
[root@localhost ~]# ls -l ./nop.exe
-rwxr-xr-x 1 root root 656 4月 24 09:13 ./nop.exe
一个简单的nop指令构成的可执行文件,竟然656字节!!
实际上起作用的只有1个字节,额外的655字节在我看来全是垃圾!有点极端,但是按照程序执行的视角来看,只要不涉及程序分发和交换,ELF这种标准化的约束就没有什么用!
我们看下纯粹的nop.exe应该是个什么样子:
[root@localhost ~]# ld -melf_x86_64 -o nop_raw.exe nop.o --oformat=binary
[root@localhost ~]# ls -l nop_raw.exe
-rwxr-xr-x 1 root root 1 4月 24 09:20 nop_raw.exe
看清楚了吗? 只有1个字节!!
遗憾的是,它跑不起来:
[root@localhost ~]# ./nop_raw.exe
./nop_raw.exe:行1: $'\220': 未找到命令
本文的目标,就是让这类纯粹的指令构成的可执行文件,跑起来!
换句话说, 达到一个目标,我们可以用十六进制编辑器徒手往一个文件里写指令,该文件里全部都是指令,然后载入该文件,从文件头第一条指令开始执行该文件。
说实话,本文由一篇知乎而起。早上坐班车上班,路上要经过暗黑的紫之隧道,便不能看书了,只能一半刷手机一半睡觉。
万事因知乎而起,知乎却往往解决不了问题:
C 如何编译出一个不需要操作系统的程序? https://www.zhihu.com/question/49580321
在看到这个问题之前,正好纠结过另一个问题: 打造最小的ELF文件
关于这个话题,有篇文章非常好:
打造史上最小可执行 ELF 文件(45 字节,可打印字符串): https://www.kancloud.cn/kancloud/cbook/69003
我曾经是想试着破45字节记录的,然而考虑到 既然想要最小,为什么需要ELF头呢?为什么不直接只包含指令呢?
马其顿的亚历山大曾经在面对 格尔迪奥斯绳结 时,选择了砍断绳子,绕过了问题,成为了亚洲的统治者。
这是正确的选择。本文中,我将展示这种砍断绳结的方法。
本文从 “打造最小ELF文件” 系列文章的相反的另一个方向,即从简单到复杂的方向,介绍如何直接用汇编指令或者机器码编程。让我们从最小的仅仅1个字节的程序开始,我敢打赌, 没有谁比我的这个nop程序更小了!
以纯粹直接的nop指令为例,还是那个代码:
.global _start
_start:
nop
我们把它编译成:
[root@localhost ~]# hexdump nop.exe
0000000 0090
当然你也可以不写汇编,毕竟汇编也是一种 稍微高级点的语言 ,你仍然需要nasm,ld这类工具将其 编译 成机器码,你可以找一个16进制编辑器直接往文件里面写机器码,不经过编译的过程,或者说你根本就没有编译器!是的,完全可以!
如何能让这么直接的程序跑起来?
我不会选择使用裸机去跑,也不想使用单片机这种简易的玩意儿,因为这些设备上无法部署可用的分析调试工具,我选择在标准的Linux系统上去跑这个指令文件。
虽然我不怎么会编程,但是能用软件解决的问题就不用专用硬件设备,我不是说过吗,软件可以无限试错!
为了让这个指令文件像一个普通的二进制ELF可执行文件那样直接在命令行执行,我们其实要做的非常简单。
在 现代操作系统(拥有独立的进程虚拟地址空间)Linux 中,我们需要做的就是三件事:
- 开辟一个新的进程P;
- 将这个文件的内容载入到该新进程P的地址空间某个地址A;
- 将该进程P的IP寄存器指向地址A。
为了让上面的三件事变成事实,我们需要了解Linux的程序加载机制。我们需要先了解ELF文件是如何载入内存并跑起来的,然后删掉一些复杂的约束,就成了。
换句话说,我们只需要照着ELF的加载程序框架比葫芦画瓢整一个更简单的加载程序就好了。幸运的是,这种加载程序在Linux内核是模块化的,我们只需要注册一个新的加载程序结构体,然后实现一些回调函数即可。
我就直接给出代码吧:
#include <linux/kernel.h>
#include <linux/mman.h>
#include <linux/binfmts.h>
#include <linux/init.h>
#include <linux/syscalls.h>
#include <asm/processor.h>
// 固定的指令空间,128MB 足够了!
#define TEXT_LEN 0x8000000
// 固定的程序加载地址
#define TEXT_BASE 0x10000000
static int load_shot_file(struct linux_binprm * bprm, unsigned long *entry)
{
unsigned long textpos = 0;
unsigned long text_len, memp = 0;
unsigned long memp_size;
unsigned long start_code, end_code;
int ret;
flush_old_exec(bprm);
text_len = PAGE_ALIGN(TEXT_LEN);
// 在新进程P中映射一段内存用于保存载入的文件中的指令,注意,是Fix到固定的TEXT_BASE处!
textpos = vm_mmap(0, TEXT_BASE, text_len, PROT_READ | PROT_EXEC | PROT_WRITE, MAP_PRIVATE, 0);
if (!textpos || IS_ERR_VALUE(textpos)) {
ret = EINVAL;;
}
memp = textpos;
memp_size = text_len;
// 将纯指令文件中的指令读取到这块地址空间中。
ret = read_code(bprm->file, textpos, 0, text_len);
if (IS_ERR_VALUE(ret)) {
vm_munmap(textpos, text_len);
return ret;
}
start_code = textpos;
end_code = textpos + text_len;
current->mm->start_data = current->mm->start_code = start_code;
current->mm->end_data = current->mm->end_code = end_code;
// 需要跳转到的首条指令的地址。
*entry = textpos;
setup_new_exec(bprm);
return 0;
}
static int load_shot_binary(struct linux_binprm * bprm)
{
struct pt_regs *regs = current_pt_regs();
unsigned long start_addr = 0;
int res;
res = load_shot_file(bprm, &start_addr);
if (IS_ERR_VALUE(res))
return res;
printk("param\n");
{
int i, j, k = 0;
unsigned char *buf = (unsigned char *)bprm->p;
for (j = 0; j < 32; j++) {
for (i = 0; i < 16; i++) {
printk(" %0x", buf[511 - k]);
k++;
}
printk("\n");
}
}
printk("\nend param\n");
// 设置stack指针,这样我们就可以使用pop,push等指令了,否则stack将是没有映射的,无法访问。
// 幸运的是,Linux的binprm机制主框架已经为所有的文件格式准备好了stack映射,我们直接用即可。
// 否则的话,我们依然要像指令空间那样,去vm mmap这个stack空间(注意,stack是growdown的)。
current->mm->start_stack = bprm->p;
current->thread.usersp = bprm->p;
regs->sp = bprm->p;
// 最关键的,设置IP指针
regs->ip = start_addr;
return 0;
}
static struct linux_binfmt shot_format = {
.module = THIS_MODULE,
.load_binary = load_shot_binary,
.min_coredump = PAGE_SIZE
};
static int __init init_shot_binfmt(void)
{
register_binfmt(&shot_format);
return 0;
}
static void __exit exit_shot_binfmt(void)
{
unregister_binfmt(&shot_format);
}
module_init(init_shot_binfmt);
module_exit(exit_shot_binfmt);
就这么简单一个加载程序就完成了,将其编译成模块,载入内核:
[root@localhost ~]# insmod ./binfmt_shot.ko
[root@localhost ~]# lsmod |grep binfmt
binfmt_shot 12538 0
OK!接下来可以写只包含指令的文件了。我们以刚刚制作好的 只有1个字节的nop.exe 为例,试试它直接执行的效果:
[root@localhost ~]# ./nop.exe
段错误
出错了!但是却是正确的!程序确实是执行了的,我们看看为什么出错:
[root@localhost ~]# dmesg |grep segfault
[40622.388632] nop.exe[26374]: segfault at 0 ip 0000000010000001 sp 00007fffffffe6a5 error 6
出错的地址是0x0000000010000001,确实是属于进程的地址空间,而且也是我们在加载程序里将指令文件载入的地址位置:
#define TEXT_BASE 0x10000000
这这证实了程序确实是执行了的!
那么为什么会出现段错误呢?很简单:
处在独立地址空间中的仅有nop指令,对于进程而言,有始(父-fork/子-exec)而无终(子-exit/父-wait)!
进程是bash里面被fork出来的,然后fork的新进程exec了nop.exe,然而我们知道,一个进程的终点是exit,这个exit需要进程自己来完成,因为父进程不知道该子进程的逻辑,当然不晓得它什么时候会终止。
现代操作系统UNIX/linux的规则: 父亲fork,孩子exec,执行完毕,孩子自己exit,父亲wait回收! 在实现中有技巧,比如exit系统调用是不可返回的,其最后调用了schedule,不会再回来!
在我们的指令文件nop.exe中,看样子进程并没有调用系统调用exit,记住,现在所有东西都要自己写咯!既然一段指令被附着在一个现代操作系统的进程中被呵护,那么这段指令就有义务完成现代操作系统的相关规则,至少是遵守相关的约定。
如果你真的不想遵循Linux这个现代操作系统的约定,死活不自己调用exit,那么就让Segmentfault来解决一切吧,反正会有人处理的,操作系统会让Segmentfault杀死违规进程的!是的!让段错误解决一切疏漏,然而什么是 段?
好吧,让我们的nop.exe来遵守这个Linux现代操作系统的约定!
来吧,让我们一气呵成:
[root@localhost ~]# cat nop_with_exit.s
.global _start
_start:
nop
# 我们手写汇编指令,主动调用exit,既然跑在Linux,只为遵守Linux约定
mov $60, %rax
xor %rdi, %rdi
syscall
[root@localhost ~]#
[root@localhost ~]# as --64 -o nop_with_exit.o nop_with_exit.s
[root@localhost ~]#
[root@localhost ~]# ld -melf_x86_64 -o nop_with_exit.exe nop_with_exit.o --oformat=binary
[root@localhost ~]# ls -l ./nop_with_exit.exe
-rwxr-xr-x 1 root root 13 4月 24 11:10 nop_with_exit.exe
[root@localhost ~]# ./nop_with_exit.exe
[root@localhost ~]# echo $?
0
代码可以跑了,并且不再报段错误!代码执行成功!
为了迎合Linux操作系统,我们增加了 13 − 1 = 12 13-1 = 12 13−1=12条关于exit的系统调用指令,这实在是不应该!然而也没有任何办法,谁让我们的代码跑在Linux上呢?
那好,让我们改复杂一点,不再仅仅nop怎么样?按照惯例,让我们来打印一句hello world吗?不!我要打印skinshoe wu!
我使用x86-64的1号系统调用sys_write,让我们看看怎么做:
.global _start
.equ TEXT_BASE, 0x10000000 # 和我们的shot加载器里的宏定义一致即可
.equ wu, TEXT_BASE + (msg - _start)
_start:
mov $wu, %rsi
mov $1, %rax # 1号系统调用
mov $1, %rdi
mov $30, %rdx
syscall
mov $60, %rax
xor %rdi, %rdi
syscall
msg:
.ascii "Skinshoe-Wu is now at Tencent!\n"
让我们再次一气呵成:
[root@localhost ~]# as --64 -o skinwu.o skinwu.s
[root@localhost ~]#
[root@localhost ~]# ld -melf_x86_64 -o skinwu skinwu.o --oformat=binary
[root@localhost ~]#
[root@localhost ~]# ./skinwu
Skinshoe-Wu is now at Tencent!
这个时候,我看看skinwu的二进制代码:
[root@localhost ~]# objdump -D skinwu
objdump: skinwu: 不可识别的文件格式
非常遗憾,objdump并不认识它,因为它不是ELF文件,这个文件只是单纯的指令,objdump没有任何义务去猜测这到底是什么文件!我们看下它大小:
[root@localhost ~]# ls -l skinwu
-rwxr-xr-x 1 root root 73 4月 24 11:26 skinwu
嗯,可能是开场介绍字符串(“Skinshoe-Wu is now at Tencent!”)长了一些。导致这个裸文件也有73字节,还算比较可观了。至少扫除了无关的ELF细节,迎来了彻底的舒爽。我们可以看到skinwu编译后的样子:
[root@localhost ~]# hexdump skinwu
0000000 c748 2ac6 0000 4810 c0c7 0001 0000 c748
0000010 01c7 0000 4800 c2c7 001e 0000 050f c748
0000020 3cc0 0000 4800 ff31 050f 6b53 6e69 6873
0000030 656f 572d 2075 7369 6e20 776f 6120 2074
0000040 6554 636e 6e65 2174 000a
如果你仔细核对其机器码,就会发现它的纯粹:
[root@localhost ~]# objdump -D skinwu.o
skinwu.o: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <_start>:
0: 48 c7 c6 2a 00 00 10 mov $0x1000002a,%rsi
7: 48 c7 c0 01 00 00 00 mov $0x1,%rax
e: 48 c7 c7 01 00 00 00 mov $0x1,%rdi
15: 48 c7 c2 1e 00 00 00 mov $0x1e,%rdx
1c: 0f 05 syscall
1e: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax
25: 48 31 ff xor %rdi,%rdi
28: 0f 05 syscall
000000000000002a <msg>:
2a: 53 push %rbx
2b: 6b 69 6e 73 imul $0x73,0x6e(%rcx),%ebp
2f: 68 6f 65 2d 57 pushq $0x572d656f
34: 75 20 jne 56 <msg+0x2c>
36: 69 73 20 6e 6f 77 20 imul $0x20776f6e,0x20(%rbx),%esi
3d: 61 (bad)
3e: 74 20 je 60 <msg+0x36>
40: 54 push %rsp
41: 65 6e outsb %gs:(%rsi),(%dx)
43: 63 65 6e movslq 0x6e(%rbp),%esp
46: 74 21 je 69 <msg+0x3f>
48: 0a .byte 0xa
根据以上我编写的那个shot二进制加载器,然后三个demo:
- 仅仅nop的demo
- nop之后exit的demo
- 打印skinshoewu之后exit的demo
我们已经知道,如果你熟悉机器码,那么便可以直接用机器码编程了,不再需要编译器,你只需要一个16进制编辑器即可。
最后在Linux系统中给予该手工编辑的文件以可执行权限:
[root@localhost ~]# chmod +x XXX
那么,这个XXX文件便可以直接被解释成指令执行了。
只几乎便是所有,除了…
除了参数的的传递!
如果一个程序没有了输入和输出,那么它连忍者神龟里的朗格都不如,就更别提史蒂芬霍金了。
程序必须要有输入,哪怕是个命令行参数传递进去的数字。没法接收输入的程序不好玩。
值得庆幸的是,将命令行参数传递给一个程序这件事在Linux系统中已经是一个非常通用的事情了。
Linux内核的二进制载入程序在fork执行后的子进程里将无论是ELF还是Flat,a.out或者我这裸指令格式的文件载入其内存之前,它 首先为该新进程映射一块内存区域作为stack堆栈! 毕竟这个stack是现代操作系统必须的:
- 现代操作系统的程序绝大多数最终通过C编码,而C以及其更上层的语言编程都是 基于函数 的,而函数调用则必须使用堆栈(x86/AMD平台)! C语言编写的代码以 “函数” 为最小单位。
虽然函数已经成了现代编程语言的基本要素以及程序员的基本认知,但是实际上 编程并不真的需要函数,函数只是一种指令的组织方式而已! 代码真正重要的只有指令本身以及指令之间的关系。函数,就当它是低级的设计模式吧!
通过review内核的相关代码,我了解到当Linux内核执行一个程序的时候,结构体linux_binprm的p字段指示了新进程的stack位置,因此,我特意在我的加载程序里打印了这个位置堆栈的512字节的数据:
{
int i, j, k = 0;
unsigned char *buf = (unsigned char *)bprm->p;
for (j = 0; j < 32; j++) {
for (i = 0; i < 16; i++) {
printk(" %0x", buf[511 - k]);
k++;
}
printk("\n");
}
}
为了测试堆栈,我新写了一个指令程序:
.global _start
_start:
pop %r12 # 先将argv[0],即文件名弹出
mov %rsp, %rsi # 将argv[1] 赋值给rsi
mov $1, %rax
mov $1, %rdi
mov $6, %rdx # 仅仅打印6个字符
syscall
# 下面执行exit系统调用
mov $60, %rax
xor %rdi, %rdi
syscall
编译为showstring,带参数执行之,我们来观察其stack空间的内存:
[root@localhost ~]# ./showstring aa bb cc dd
ringa[root@localhost ~]# # 这里并没有达到预期,我们本希望程序打印aa的!
[root@localhost ~]# dmesg # 通过dmesg看看stack里面究竟是什么。
...
[63007.360934] 53 45 53 5f 47 44 58 0 64 64 0 63 63 0 62 62
[63007.360939] 0 61 61 0 67 6e 69 72 74 73 77 6f 68 73 2f 2e
dmesg打印的信息是内核将参数拷贝到的区域的内存,我们可以看到,从stack的顶部,也就是打印值的末尾开始,字符串信息分别为:
67 6e 69 72 74 73 77 6f 68 73 2f 2e
61 61
62 62
63 63
64 64
...
由于我是倒序打印的,所以要倒着看。
- 第一行 67 6e 69 72 74 73 77 6f 68 73 2f 2e 倒着看就是 ./showstring
- 第二行 61 61 就是 aa
- 第三行 62 62 就是 bb
- …
这就是用户程序堆栈里初始化的内容,它们原来就是命令行参数啊!多亏了Linux内核的binprm框架自动将命令行参数拷贝到了用户进程stack页面,我们便不必再单独分配内存空间了。 感谢Linix内核程序载入框架的优美!
现在,为了让程序打印第一个参数,需要 解析字符串 。 我勒个去,用汇编解析字符串不是我擅长的,虽然这里所谓的解析仅仅是按照以 \0 分割字符串即可,但这也不是我的菜,所以为了保持汇编代码的简洁性,我不准备修改代码。
我们看了一个新生态进程的stack布局之后,发现它竟然是一系列以 \0 分隔的字符串,如果我们用pop来 弹出一个元素作为地址 ,那将是不合时宜的,所以为了让pop之后的rsp寄存器指向一个真正的字符串地址(字符串地址就是字符数组的第一个元素的地址,这个谭浩强讲过),我们就必须让可执行文件的名称加上 ./ 以及其后的 \0 之后,正好是一个地址的长度。
我们知道,在64位系统,一个地址的长度是8个字节,减去 ./ 两个字节和 \0 一个字节,这就需要可执行文件的名字是5个字节。
好吧,我们来吧:
[root@localhost ~]# mv showstring abcde
[root@localhost myflat]# ./abcde hello
hello[root@localhost myflat]# # 达到了效果,按照代码,这里算上‘\0’ 仅仅打印6个字符!
最后,我们再来看看已经从showstring改名为abcde的这个可执行程序的内容:
[root@localhost ~]# objdump -D ./abcde
objdump: ./abcde: 不可识别的文件格式
很遗憾,它可以执行,和前面的例子一样,操作系统不认识它,它不是ELF格式的,毕竟它只是我的trick,超级小众!
那么好,我们直接看它的二进制内容:
[root@localhost ~]# file ./abcde
./abcde: data
[root@localhost ~]# hexdump ./abcde
0000000 5c41 8948 48e6 c0c7 0001 0000 c748 01c7
0000010 0000 4800 c2c7 0006 0000 050f c748 3cc0
0000020 0000 4800 ff31 050f
这可是赤裸裸的指令啊!不信?你看,我们将showsting.s的汇编码列如下:
[root@localhost myflat]# objdump -D ./showstring.o
./showstring.o: 文件格式 elf64-x86-64
Disassembly of section .text:
0000000000000000 <_start>:
0: 41 5c pop %r12
2: 48 89 e6 mov %rsp,%rsi
5: 48 c7 c0 01 00 00 00 mov $0x1,%rax
c: 48 c7 c7 01 00 00 00 mov $0x1,%rdi
13: 48 c7 c2 06 00 00 00 mov $0x6,%rdx
1a: 0f 05 syscall
1c: 48 c7 c0 3c 00 00 00 mov $0x3c,%rax
23: 48 31 ff xor %rdi,%rdi
26: 0f 05 syscall
和上面的hexdump的结果做对比,是不是完全一样的呢?
好了,大体就是这个样子。快要结束了。
如果载入了我这个shot加载器到内核,对系统有什么副作用呢?
在Linux系统中,内核为了获取 “如何执行一个程序” 这个信息,采用了一个 链式匹配 的过程,每一类可执行文件都具备一个 特征 包含在可执行文件本身,比如文件的最开头的位置。
根据这些特征,Linux可执行文件可以分为两大类:
- 解释执行的脚本
脚本文件的第一行如果以 “#!” 开头,那么它就是一个脚本,而该magic符号后面字符串将会被认为是该脚本文件的解释器程序的位置,Linux内核将递归载入该解释器,然后将执行权限交接给解释器程序的入口。 - 直接执行的ELF文件
ELF文件以 0x7f ELF 作为Magic数字来标识这是一个ELF二进制可执行文件。匹配的文件将会根据ELF头的指引被载入进程地址空间,然后将执行权限交接给该文件的entry指针指向的位置。
无论以上哪一类程序,很多可执行程序都是在bash中运行的,也就是说当前的bash负责fork一个子进程,然后在子进程中exec指示的可执行程序,整个过程的exec前,bash做了一些预处理,或者说预检查:
- bash检查这个可执行程序是不是一个 "#!" 开头的脚本,如果是,直接调用exec执行脚本;
- 如果不是一个 "#!" 开头的脚本,但是却是一个文本文件,那么bash会尝试展开这个文件,另起一个bash来执行每一行命令;
- 如果是一个二进制程序,那么bash将直接调用exec系统调用,交给内核处理。
我们来验证一下。编写下面的脚本:
[root@localhost ~]# cat kk
#!/bin/bash
echo $$
# 获取当前的pid,预期的结果应该是一个bash
pid=$$
cat /proc/$pid/cmdline
echo
# 获取当前进程的父进程pid,预期的结果应该是当前终端的bash
cat /proc/$pid/status |grep PPid
# 获取cat进程的父进程pid,预期结果是当前bash的pid
cat /proc/self/status |grep PPid
echo
执行结果是:
[root@localhost ~]# ./kk
31608
/bin/bash./kk # 按照 #!后面的解释器将kk作为参数传递
PPid: 28404
PPid: 31608
如果说我们把脚本的第一行去掉,将其变为kk2:
# 获取当前的pid,预期的结果应该是一个bash
pid=$$
cat /proc/$pid/cmdline
echo
# 获取当前进程的父进程pid,预期的结果应该是当前终端的bash
cat /proc/$pid/status |grep PPid
# 获取cat进程的父进程pid,预期结果是当前bash的pid
cat /proc/self/status |grep PPid
echo
照理说程序执行应该会报错,但是并不会,我们试一下:
[root@localhost ~]# ./kk2
31696
-bash # 进程名字变了,不再传递参数,而是直接解释执行
PPid: 28404
PPid: 31696
也就是说,不管你带不带文件第一行的 #! 解释器,bash均有能力帮你用bash去处理执行这个脚本。这是bash帮你做的善事,而不是内核做的:
- bash执行两次执行,首先直接调用exec,如果第一次执行出错了,就自己解释二次执行。
我们不用bash启动这个脚本,用一个别的程序来fork/exec这个kk2,就会报错了。最好的验证方法就是用strace,不光能看到整个结果,还能看系统调用返回结果呢:
[root@localhost ~]# strace -f ./kk
execve("./kk2", ["./kk2"], [/* 25 vars */]) = -1 ENOEXEC (Exec format error)
write(2, "strace: exec: Exec format error\n", 32strace: exec: Exec format error
) = 32
exit_group(1) = ?
+++ exited with 1 +++
嗯,是的,这次strace没有帮忙处理,所以直接透传了内核的错误反馈,即ENOEXEC:
static int load_script(struct linux_binprm *bprm)
{
const char *i_arg, *i_name;
char *cp;
struct file *file;
char interp[BINPRM_BUF_SIZE];
int retval;
if ((bprm->buf[0] != '#') || (bprm->buf[1] != '!'))
return -ENOEXEC; // 就是这个错误返回!
...
}
说了这么多的花絮,我要说的是,如果载入了我的shot加载器,那么 任何文件,只要有了可执行权限,无论是文本还是二进制,只要不命中ELF,Script这种,都将会在链式匹配中匹配到shot加载器并被当作指令文件直接执行!
也就是说,只要载入了shot加载器,bash将不会有第二次机会去解释执行,为此,我们做个小实验。
首先不加载shot加载器,我们准备一个二进制文件和一个文本,然后执行:
[root@localhost ~]# dd if=/dev/zero of=./data bs=1 count=10
记录了10+0 的读入
记录了10+0 的写出
10字节(10 B)已复制,0.000141455 秒,70.7 kB/秒
[root@localhost ~]# chmod +x data
[root@localhost ~]# ./data
-bash: ./data: 无法执行二进制文件
[root@localhost ~]#
[root@localhost ~]# cat text
aaa
bbb
ccc
[root@localhost ~]# chmod +x text
[root@localhost ~]# ./text
./text:行1: aaa: 未找到命令
./text:行2: bbb: 未找到命令
./text:行3: ccc: 未找到命令
bash并不认识二进制文件,但是bash认识文本,所以对于二进制文件,便是无法执行,对于文本文件,确实逐行去解释执行了,所以我们看到的结果便是,bash并不认识aaa,bbb,ccc命令。
好了,现在载入shot加载器,同样执行上面的data和text:
[root@localhost ~]# ./text
非法指令
[root@localhost ~]# dmesg |tail -n 1
[92491.466475] traps: text[32731] trap invalid opcode ip:10000000 sp:7fffffffe6e5 error:0
我的shot加载器将其作为二进制载入内存了,当执行aaa的二进制指令码时,报错,从内核信息中可见一斑。现在我们执行二进制的data:
[root@localhost ~]# ./data
段错误
嗯,也是预期之中,不多说。
本文应该到此为止了。提到的内容也算是非常简单,然而我敢肯定并不是每个人都会的。
如今程序员这个职业已经被繁重的业务压迫覆盖太久了,大家疲于闭着眼睛编写业务逻辑,个个都是熟练工,然而一旦遇到一些莫名其妙的疑难杂症,就不知道该如何。这并不是什么问题,毕竟这就是社会分工。
开车的越来越不懂车了,纺织厂的工人也并不懂什么纺织技术,建筑工地的钢筋工不需要懂力学,基本也是这个道理。就当本文是份开胃小食吧。
本文介绍了一种Linux内核的方案,如果要做这个方案,至少要懂Linux内核吧。然而,事实上根本不需要Linux内核,若想直接执行一个仅包含指令的文件,我们要做的核心工作仅仅是两件事:
- 把指令文件载入内存的一个位置。
- 跳转到该位置的开头执行。
这两件事用一个用户态程序来做太简单了,根本不需要Linux内核加载器的支持,甚至可以跑在Windows上,我们要做的仅仅是修改一下bash程序即可,或者说,作为测试demo,写一个独立的小程序。
下一篇再说吧。参见:
用户态直接执行仅包含二进制指令的文件: https://blog.csdn.net/dog250/article/details/89599592
浙江温州皮鞋湿,下雨进水不会胖。