这个由GNU ld生成、使用仅包含一个.text段的链接脚本的最小静态ELF可执行文件有什么问题?

编程语言 2026-07-10

本题末尾的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的内核栈,如是我记得的话。)

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章