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;
复制代码
节的类型相关常量以SHT_开头
接下来说几个比较重要的段
什么是导入表(Import Table)
导入表记录了一个 .so 文件依赖的外部符号(函数或变量),这些符号来自其他共享库,运行时由动态链接器进行解析。
导入表体现在哪些节中?
什么是导出表(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就是它的方法了,我们点进去看可以看到