这个由GNU ld生成、使用仅包含一个.text段的链接脚本的最小静态ELF可执行文件有什么问题?
本题末尾的ELF可执行文件(经过base64编码)是把程序头尽可能缩减到最小的实验结果。据我所知,它应该是完全有效的。然而,Linux的可执行加载器拒绝了它:
$ strace ./experiment
execve("./experiment", ["./experiment"], 0x7ffee000bd20 /* 69 vars */) = -1 EPERM (Operation not permitted)
以下是文件及程序头部的 readelf 转储:
$ readelf -hl ./experiment
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x120
Start of program headers: 64 (bytes into file)
Start of section headers: 512 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 4
Size of section headers: 64 (bytes)
Number of section headers: 6
Section header string table index: 5
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000190 0x0000000000000190 R E 0x1000
NOTE 0x0000000000000160 0x0000000000000160 0x0000000000000160
0x0000000000000030 0x0000000000000030 R 0x8
GNU_PROPERTY 0x0000000000000160 0x0000000000000160 0x0000000000000160
0x0000000000000030 0x0000000000000030 R 0x8
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
Section to Segment mapping:
Segment Sections...
00 .text .note.gnu.property
01 .note.gnu.property
02 .note.gnu.property
03
Linux的程序加载器到底不喜欢这个文件的哪一部分?
base64编码的程序
f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAIAEAAAAAAABAAAAAAAAAAAACAAAAAAAAAAAAAEAAOAAE
AEAABgAFAAEAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkAEAAAAAAACQAQAAAAAAAAAQ
AAAAAAAABAAAAAQAAABgAQAAAAAAAGABAAAAAAAAYAEAAAAAAAAwAAAAAAAAADAAAAAAAAAACAAA
AAAAAABT5XRkBAAAAGABAAAAAAAAYAEAAAAAAABgAQAAAAAAADAAAAAAAAAAMAAAAAAAAAAIAAAA
AAAAAFHldGQGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAA
AAAASInnSIHnAPD//0jHxgAQAABIx8ALAAAADwVIhcB1C0jHwCIAAAAPBev1SInHSPffSMfAPAAA
AA8FAAAAAAAAAAQAAAAgAAAABQAAAEdOVQABAAHABAAAAAEAAAAAAAAAAgABwAQAAAABAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAASAAEAIAEAAAAAAAA5AAAAAAAAAABfc3RhcnQA
AC5zeW10YWIALnN0cnRhYgAuc2hzdHJ0YWIALnRleHQALm5vdGUuZ251LnByb3BlcnR5AAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAGwAAAAEAAAAGAAAAAAAAACABAAAAAAAAIAEAAAAAAAA5AAAAAAAAAAAAAAAAAAAAAQAA
AAAAAAAAAAAAAAAAACEAAAAHAAAAAgAAAAAAAABgAQAAAAAAAGABAAAAAAAAMAAAAAAAAAAAAAAA
AAAAAAgAAAAAAAAAAAAAAAAAAAABAAAAAgAAAAAAAAAAAAAAAAAAAAAAAACQAQAAAAAAADAAAAAA
AAAABAAAAAEAAAAIAAAAAAAAABgAAAAAAAAACQAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAwAEAAAAA
AAAIAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAABEAAAADAAAAAAAAAAAAAAAAAAAAAAAA
AMgBAAAAAAAANAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAA=
应该能够重现程序的步骤
cat > experiment.S <<\EOF
#include <sys/syscall.h>
.text
.global _start
.type _start, @function
_start:
mov %rsp, %rdi
and $(~4095), %rdi
mov $4096, %rsi
mov $SYS_munmap, %rax
syscall
test %rax, %rax
jne .Lfail
.Lloop:
mov $SYS_pause, %rax
syscall
jmp .Lloop
.Lfail:
mov %rax, %rdi
neg %rdi
mov $SYS_exit, %rax
syscall
.size _start, .-_start
.section .note.GNU-stack,"",@progbits
EOF
cat > experiment.ld <<\EOF
OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64", "elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(_start)
SECTIONS
{
. = SEGMENT_START("text-segment", 0) + SIZEOF_HEADERS;
.text : { *(.text) }
}
EOF
cc -P -E experiment.S | as -o experiment.o -
ld -o experiment -static -e _start -T experiment.ld experiment.o
解决方案
GNU Binutils ld 的默认行为是生成一个传统的ELF可执行文件(ET_EXEC),其中程序头中的地址是绝对的并直接使用。(因此标签的最终绝对地址是在链接时确定的常量,可以用作disp32/imm32,而不需要RIP相对的LEA。)
你的 . = SEGMENT_START("text-segment", 0) + SIZEOF_HEADERS; 将绝对地址设为 0,请求内核将该段加载到 VirtAddr = 0x0。如果把 mmap_min_addr = 0 设置成非默认的64K,程序可能就能运行。(https://wiki.debian.org/mmap_min_addr。)
但更明智地做法是使用与 ld 的默认脚本相同的设置,4 MiB = 0x400000:
为什么Linux/gnu链接器要选0x400000地址?
你的链接脚本对PIE来说工作正常,其中 0 只是内核为镜像基址选择的一个偏移量。 显然这是你所期望的。这是大多数现代发行版上的GCC的默认设置,但你直接调用 ld,因此你需要 -pie。
静态PIE仍然是PIE(ET_DYN而非ET_EXEC),但没有ELF解释器(动态链接器)。GCC和 ld 需要一些特殊选项来请求这一点,因为它需要 _start 来应用运行时重定位(如果你有任何64位绝对地址,例如跳转表或静态指针),以替代让动态链接器在跳转到你的 _start 之前处理那些修正。
从Linux ldd看,“静态链接”和“不是动态可执行文件”有什么区别?
默认情况下,ld -pie 表示使用动态链接器;请使用 ld -pie --no-dynamic-linker 将其改为静态PIE(-static 增加到 -pie --no-dynamic-linker 时没有区别。)
或者 gcc -nostdlib -static-pie -Wl,-Texperiment.ld foo.S,它会使文件大88字节,在第二个NOTE段中包含一个构建ID。
$ ld -o experiment -pie -static --no-dynamic-linker -e _start -T experiment.ld experiment.o
/usr/bin/ld: warning: experiment has a LOAD segment with RWX permissions
$ ls -l experiment
-rwxr-xr-x ... 1640 bytes ... experiment
$ strip -s --strip-section-headers experiment # now it's only 760 bytes
$ readelf -a experiment
...
Type: DYN (Position-Independent Executable file)
...
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x00000000000002f8 0x00000000000002f8 RWE 0x1000
DYNAMIC 0x0000000000000218 0x0000000000000218 0x0000000000000218
0x00000000000000e0 0x00000000000000e0 RW 0x8
NOTE 0x0000000000000198 0x0000000000000198 0x0000000000000198
0x0000000000000030 0x0000000000000030 R 0x8
GNU_PROPERTY 0x0000000000000198 0x0000000000000198 0x0000000000000198
0x0000000000000030 0x0000000000000030 R 0x8
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
...
$ strace ./experiment
execve("./experiment", ["./experiment"], 0x7ffc51c35080 /* 65 vars */) = 0
munmap(0x7ffd934a0000, 4096) = 0
pause(
在我的系统上,这仍然会映射出一页脏栈,总RSS为 8K。/proc/<PID>/smaps 显示仍然存在一个 7ffd934a1000-7ffd934a3000 rw-p 00000000 00:00 0 的映射(比传给munmap的地址高出2 页),并且有 Private_Dirty: 4 kB。所以我猜这是还有更多的环境变量。
munmap 只有在该区域不包含任何有效映射时才会失败,所以你可以简单地指定一个更大的大小,例如4 页。
例如,使用FASM制作一个更极简的静态可执行文件(151字节,起初就没有节头因此不需要 strip 来移除它们,只有一个LOAD段,没有DYNAMIC / NOTE / GNU_PROPERTY / GNU_STACK)。
format ELF64 executable ; traditional ET_EXEC, not a PIE or an object file
;global _start
_start:
mov rdi, rsp
and rdi, -4096
mov esi, 4 * 4096 ; 4 pages
mov eax, 11 ; _NR_munmap
syscall
; omitted your version's error checking, I just run it under strace
pauseloop:
mov eax, 34 ; _NR_pause
syscall
jmp pauseloop
; assemble with fasm unmap-stack.asm to produce an executable directly, no linker
如果你只是使用 nasm + ld 以及其默认的链接脚本,将得到一个4.7 KiB的静态可执行文件,包含两个LOAD段。(如果你省略任何 section .gnu_stack 指令。)一个用于元数据和填充,另一页才是 .text 段的实际机器码。
使用 as + ld(或 gcc -static -nostdlib)时情况更糟,得到一个8.8 KiB的静态可执行文件,映射3 页并含3 个LOAD段。因此如果你不想为这几页再写更多汇编来让 munmap,选择链接脚本是正确的。并且能让磁盘文件更小。
另一种选择是把机器码拷贝到可执行栈,并取消映射 .text。但这更复杂,并且意味着你保留映射的页面是匿名且脏的,仅由交换空间支撑。通过取消映射栈并保留 .text,这是一个由磁盘文件支撑的干净映射,因此在内存压力下可以被内核直接驱逐,而无需将其写回磁盘任何地方。(同一程序的多个实例也可能共享那一页 .text 的代码,但内核记账和内核栈仍会占用内存;在x86-64如今每个任务大约16 KiB的内核栈,如是我记得的话。)