ELF魔数
每种可执行文件的格式的开头几个字节都是很特殊的,特别是开头4个字节,通常被称为魔数(Magic Number)。通过对魔数的判断可以确定文件的格式和类型。如:ELF的可执行文件格式的头4个字节为0x7F、e、l、f;Java的可执行文件格式的头4个字节为c、a、f、e;如果被执行的是Shell脚本或perl、python等解释型语言的脚本,那么它的第一行往往是#!/bin/sh或#!/usr/bin/perl或#!/usr/bin/python,此时前两个字节#和!就构成了魔数,系统一旦判断到这两个字节,就对后面的字符串进行解析,以确定具体的解释程序路径。
ELF文件类型
ELF文件主要有三种类型,可以通过ELF Header中的e_type成员进行区分。
可重定位文件(Relocatable File):ETL_REL。一般为.o文件。可以被链接成可执行文件或共享目标文件。静态链接库属于可重定位文件。
可执行文件(Executable File):ET_EXEC。可以直接执行的程序。
共享目标文件(Shared Object File):ET_DYN。一般为.so文件。有两种情况可以使用。链接器将其与其他可重定位文件、共享目标文件链接成新的目标文件;
动态链接器将其与其他共享目标文件、结合一个可执行文件,创建进程映像。 (感兴趣的可以自己查看一下加深印象)
ELF Section Header Table
ELF 文件中的
Section Header Table(节头表) 是描述每个 Section(节)信息的一个结构数组,它存在于所有的 ELF 文件中(包括 .so、.o、可执行文件)。
每个节(Section)包含一类特定的数据,例如代码、符号表、字符串表、调试信息等,Section Header Table 就是描述这些节的元信息的集合。
32 位 ELF 文件中,Elf32_Shdr 的定义如下(64 位结构类似):
- typedef struct {
- uint32_t sh_name; // 节名称的字符串表索引
- uint32_t sh_type; // 节的类型(SHT_PROGBITS 等)
- uint32_t sh_flags; // 属性标志(可读、可写、可执行等)
- uint32_t sh_addr; // 在内存中的虚拟地址
- uint32_t sh_offset; // 文件中的偏移地址
- uint32_t sh_size; // 大小(字节)
- uint32_t sh_link; // 与其他节的链接(如关联的符号表)
- uint32_t sh_info; // 补充信息
- uint32_t sh_addralign; // 对齐要求
- uint32_t sh_entsize; // 如果节中包含固定大小条目,此为每条目大小
- } Elf32_Shdr;
复制代码
typedef struct { uint32_t sh_name; // 节名称的字符串表索引 uint32_t sh_type; // 节的类型(SHT_PROGBITS 等) uint32_t sh_flags; // 属性标志(可读、可写、可执行等) uint32_t sh_addr; // 在内存中的虚拟地址 uint32_t sh_offset; // 文件中的偏移地址 uint32_t sh_size; // 大小(字节) uint32_t sh_link; // 与其他节的链接(如关联的符号表) uint32_t sh_info; // 补充信息 uint32_t sh_addralign; // 对齐要求 uint32_t sh_entsize; // 如果节中包含固定大小条目,此为每条目大小} Elf32_Shdr;
节类型(sh_type)节名是一个字符串,只是在链接和编译过程中有意义,但它并不能真正地表示节的类型。对于编译器和链接器来说,主要决定节的属性是节的类型(sh_type)和节的标志位(sh_flags)。节的类型相关常量以SHT_开头
常见的节类型如下表所示
[td]
| 十进制值 | 宏定义名 | 描述说明 |
| 0 | SHT_NULL | 无效节头项(一般是第一个,表示未使用) |
| 1 | SHT_PROGBITS | 程序代码或数据段,如 .text、.data |
| 2 | SHT_SYMTAB | 静态符号表(含函数名、变量名等,调试使用) |
| 3 | SHT_STRTAB | 字符串表,提供节名、符号名等字符串 |
| 4 | SHT_RELA | 重定位表,带显式 addend(偏移修正值) |
| 5 | SHT_HASH | 符号哈希表,用于动态链接 |
| 6 | SHT_DYNAMIC | 动态链接信息,供动态加载器使用 |
| 7 | SHT_NOTE | 附加注释/元数据(如 Android ABI、Build ID) |
| 8 | SHT_NOBITS | 不占用实际文件空间的节,如 .bss |
| 9 | SHT_REL | 重定位表,不带 addend |
| 10 | SHT_SHLIB | 保留,暂未使用 |
| 11 | SHT_DYNSYM | 动态符号表(给动态链接器用,包含全局符号) |
| 14 | SHT_INIT_ARRAY | 构造函数数组(初始化代码指针) |
| 15 | SHT_FINI_ARRAY | 析构函数数组(清理代码指针) |
| 16 | SHT_PREINIT_ARRAY | 程序入口前调用的函数指针 |
| 17 | SHT_GROUP | Section Group 信息(如用于 comdat 组) |
| 18 | SHT_SYMTAB_SHNDX | 用于扩展 .symtab 中的节索引字段 |
| 0x6ffffff6 | SHT_GNU_HASH | GNU 扩展的哈希表,用于加快符号查找 |
| 0x70000000~ | SHT_LOPROC | 处理器相关(私有用途) |
接下来说几个比较重要的段
[td]
| 节名称 | 作用说明 | 所在段 | 节类型(sh_type) |
| .text | 程序代码指令 | .text | SHT_PROGBITS |
| .rodata | 只读常量(如字符串) | .text | SHT_PROGBITS |
| .plt | 动态库函数调用跳板(过程链接表) | .text | SHT_PROGBITS |
| .data | 初始化的全局变量 | .data | SHT_PROGBITS |
| .bss | 未初始化的全局变量(加载时清零) | .data | SHT_NOBITS |
| .got.plt | 全局偏移表(供链接器运行时修改) | .data | SHT_PROGBITS |
| .dynsym | 动态符号表(共享库函数的符号信息) | .text | SHT_DYNSYM |
| .dynstr | 动态字符串表(动态符号名称) | .text | SHT_STRTAB |
| .rel.* | 重定位表(无 addend 的重定位信息) | — | SHT_REL |
| .rela.* | 重定位表(有 addend 的重定位信息) | — | SHT_RELA |
| .hash | GNU 哈希表,用于加快符号解析 | .text | SHT_HASH / SHT_GNU_HASH |
| .symtab | 静态符号表(编译器调试使用) | .text 或无段 | SHT_SYMTAB |
| .strtab | 静态字符串表(供 .symtab 使用) | .text 或无段 | SHT_STRTAB |
| .ctors | 构造函数表(程序执行前自动运行) | .data | SHT_INIT_ARRAY |
| .dtors | 析构函数表(程序执行后自动运行) | .data | SHT_FINI_ARRAY |
上面我们介绍了.dynsym节和.symtab节,两者都是符号表。那么它们到底有什么区别呢?存在什么关系呢?
符号表
[td]
| 名称 | 作用 |
| .dynsym | 为 动态链接器(如ld.so) 提供必要的符号信息,用于程序运行时解析共享库中的函数和变量 |
| .symtab | 为 调试器、反汇编器 和链接器使用,包含完整的符号信息,用于静态链接、调试等目的 |
[td]
| 比较项 | .dynsym | .symtab |
| 包含符号 | 仅包含动态链接需要的外部符号 | 包含全部符号(局部 + 全局 + 调试用) |
| 符号数量 | 少(仅用于动态链接) | 多(包含局部变量、函数、调试符号等) |
| 是否包含局部符号 | 否 | 是 |
总结:.symtab不是在运行时必需的,因此不会被装载到内存中。.dynsym保存的符号只能在运行时被解析,因此是运行时动态链接器所需的唯一符号。.dynsym对于动态链接可执行文件的执行是必需的,而.symtab只是用来进行调试和链接的。
什么是导入表(Import Table)
导入表记录了一个 .so 文件依赖的外部符号(函数或变量),这些符号来自其他共享库,运行时由动态链接器进行解析。
导入表体现在哪些节中?[td]
| 节名称 | 说明 |
| .dynsym | 动态符号表,包含所有导入/导出符号 |
| .dynstr | 动态字符串表,保存符号名称 |
| .rel.plt / .rela.plt | 重定位信息(用于懒加载) |
| .got.plt | GOT表,用于存放外部符号地址 |
| .dynamic | 含有 DT_NEEDED(依赖库)、DT_SYMTAB 等指针,供加载器解析 |
什么是导出表(Export Table)
导出表列出了一个 .so 文件
对外暴露的符号,即其他程序或库可以调用的函数和变量。导出符号同样保存在 .dynsym 中。
基础NDK开发(确保已配备开发环境)
新建一个NDK项目,之后我们看到
native-lib.cpp,native层的代码就在此处编写了。
初步了解静态注册和动态注册
静态注册(Static Registration)
优点:使用固定命名格式,系统自动匹配 Java 方法和 native 实现。 简单直接,不需要手动注册。
缺点:每个函数名非常长,容易出错,不灵活。且逆向成本较低
动态注册(Dynamic Registration)
优点:使用任意函数名,通过 JNI_OnLoad 中显式注册 结构清晰,支持混淆类名/方法名
缺点: 如果注册失败不会编译报错,只会在运行时崩溃或方法调用失败
现在,让我们来注册两个native函数(一个实现静态注册,另一个实现动态注册)
新建一个类math,接下来在这个类里这么写
- package com.lingxing.myapplication;
- public class math {
- public static native int add(int a , int b);
- public static native int sub(int a , int b);
- }
复制代码
之后,我们开始在native注册。
- #include <jni.h> // JNI 头文件
- #include <string>
- extern "C" JNIEXPORT jstring JNICALL
- Java_com_lingxing_myapplication_MainActivity_stringFromJNI(
- JNIEnv* env, // JNI 接口指针,用于调用 JNI 提供的各种函数
- jobject /* this */) { // Java 中调用该方法的对象引用,这里未使用,可省略参数名
- std::string hello = "Hello from C++";
- return env->NewStringUTF(hello.c_str());
- }
- extern "C" JNIEXPORT jint JNICALL
- Java_com_lingxing_myapplication_math_add(
- JNIEnv *env, // JNI 接口指针
- jclass clazz, // 调用该静态 native 方法的 Java 类引用
- jint a, // 从 Java 传入的第一个 int 参数
- jint b) { // 从 Java 传入的第二个 int 参数
- return a + b;
- }
- jint sub(JNIEnv* env, jclass clazz, jint a, jint b) {
- return a - b;
- }
- static JNINativeMethod methods[] = {
- // {Java 层方法名, 方法签名, 对应的 C 函数指针}
- // 方法签名 (II)I 表示:接收两个 int (I, I),返回一个 int (I)
- {"sub", "(II)I", (void*)sub}
- };
- // JNI 默认加载函数,当 Java 层使用 System.loadLibrary() 加载本地库时会调用此函数
- JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* reserved) {
- JNIEnv* env = nullptr;
- // 获取 JNI 环境,指定最低支持的 JNI 版本为 1.6
- if (vm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK) {
- return JNI_ERR; // 获取失败则返回错误,库加载失败
- }
- // 查找要注册 native 方法的 Java 类,使用全路径,包名与类名之间用 '/' 分隔
- jclass clazz = env->FindClass("com/lingxing/myapplication/math");
- if (clazz == nullptr) {
- return JNI_ERR; // 找不到类,返回错误
- }
- // 动态注册 methods 数组中定义的 native 方法
- // 第三个参数是 methods 数组长度
- if (env->RegisterNatives(
- clazz,
- methods,
- sizeof(methods) / sizeof(methods[0])
- ) < 0) {
- return JNI_ERR; // 注册失败,返回错误
- }
- // 返回支持的 JNI 版本号
- return JNI_VERSION_1_6;
- }
复制代码接下来我们就完成了静态注册和动态注册,就可以用IDA看导出表了
直接搜java就能看到静态注册的方法了(动态注册下次再讲,三种对抗方法),点进去f5之后按y把一个参数改为
这样来说对于jni函数伪c效果更好,但是这里逻辑太简单,所以无所谓改不改。
动态注册的话,咱们也可以静态看一看,静态其实不太好看。
导出表搜索JNI_OnLoad。
可以看到
- if ( (*(int (__fastcall **)(_QWORD, __int64, _UNKNOWN **, __int64))(*(_QWORD *)v5[0] + 1720LL))(
- v5[0],
- v4,
- &off_40108,
- 1) < 0 )
复制代码那么这其中的
off_40108就是它的方法了,我们点进去看可以看到
- Z3subP7_JNIEnvP7_jclassii ; sub(_JNIEnv *,_jclass *,int,int)
复制代码双击点进去就是减法的逻辑了
结帖结帖