NDK crash(signal 11 (SIGSEGV)) 分析历程

Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x72a2ab8f40 in tid 20224...

我们收到一个来自于Android11 的crash error,很少做NDK相关,看得我一脸懵。

11-17 22:25:55.604 10114 14417 20224 F libc : Fatal signal 11 (SIGSEGV), code 2 (SEGV_ACCERR), fault addr 0x72a2ab8f40 in tid 20224 (...), pid 14417 (roid.app.camera)
11-17 22:26:11.921 10114 20280 20280 F DEBUG : backtrace:
11-17 22:26:11.921 10114 20280 20280 F DEBUG : #00 pc 000000000036243c /apex/com.android.art/lib64/libart.so (art::(anonymous namespace)::GuardedCopy::Check(char const, void const, bool)+20) (BuildId: 4282922bc58d6d4ba18963d4940dba75)
11-17 22:26:11.921 10114 20280 20280 F DEBUG : #01 pc 0000000000364bd4 /apex/com.android.art/lib64/libart.so (art::(anonymous namespace)::GuardedCopy::ReleaseGuardedPACopy(char const, _JNIEnv, _jarray, void, int)+632) (BuildId: 4282922bc58d6d4ba18963d4940dba75)
11-17 22:26:11.921 10114 20280 20280 F DEBUG : #02 pc 000000000036438c /apex/com.android.art/lib64/libart.so (art::(anonymous namespace)::CheckJNI::ReleasePrimitiveArrayElements(char const, art::Primitive::Type, _JNIEnv, _jarray, void, int)+712) (BuildId: 4282922bc58d6d4ba18963d4940dba75)

看trace是发生在了ReleasePrimitiveArrayElements。Code 差不多是这样的, 也就是ReleasePrimitiveArrayElements发现数组越界了。

{
    // jbyteArray data
    unsigned char *input = new unsigned char[len];
    env->GetByteArrayRegion(data, 0, len, reinterpret_cast(input));

    ... // actions
    env->ReleaseByteArrayElements(data, reinterpret_cast(input), 0);
}

当然最开始我并不理解什么叫数组越界,本能先考虑的是,数组出问题了吧?是不是空的?

{
    if (data == nullptr || input == nullptr) {
        LOG("data == null or input == null");
        return;
    } 
}

那显然呢一定是完全没用啊
其实如果你故意把它设置为null的时候你就会发现空指针的报错是signal 6,而且会直白地告诉你non-nullable argument was NULL。

A/DEBUG: signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
A/DEBUG: Abort message: 'JNI DETECTED ERROR IN APPLICATION: non-nullable argument was NULL
in call to ReleaseByteArrayElements
from boolean ...(byte[], double, double, double, double)'
A/DEBUG: backtrace:
A/DEBUG: #00 pc 00000000000832a8 /apex/com.android.runtime/lib64/bionic/libc.so (abort+160) (BuildId: b0750023d0cf44584c064da02400c159)
A/DEBUG: #01 pc 00000000004ba634 /apex/com.android.runtime/lib64/libart.so (art::Runtime::Abort(char const)+2388) (BuildId: c79d8488d870b3079640a498165bbfd0)
A/DEBUG: #02 pc 000000000000b458 /system/lib64/libbase.so (android::base::LogMessage::~LogMessage()+580) (BuildId: 36cd125456a5320dd3dcb8cfbd889a1a)
A/DEBUG: #03 pc 0000000000378c18 /apex/com.android.runtime/lib64/libart.so (art::JavaVMExt::JniAbort(char const
, char const)+1584) (BuildId: c79d8488d870b3079640a498165bbfd0)
A/DEBUG: #04 pc 0000000000378e3c /apex/com.android.runtime/lib64/libart.so (art::JavaVMExt::JniAbortV(char const
, char const, std::__va_list)+108) (BuildId: c79d8488d870b3079640a498165bbfd0)
A/DEBUG: #05 pc 000000000036b264 /apex/com.android.runtime/lib64/libart.so (art::(anonymous namespace)::ScopedCheck::AbortF(char const
, ...)+136) (BuildId: c79d8488d870b3079640a498165bbfd0)
A/DEBUG: #06 pc 00000000003754a8 /apex/com.android.runtime/lib64/libart.so (art::(anonymous namespace)::CheckJNI::ReleasePrimitiveArrayElements(char const, art::Primitive::Type, _JNIEnv, _jarray, void, int)+1908) (BuildId: c79d8488d870b3079640a498165bbfd0)

所以排除了空指针的可能性

所以,好了,别猜了,来理解一下吧。
https://source.android.com/devices/tech/debug/native-crash

这个错误叫:Execute-only memory violation (Android 10 only) 只执行内存违规

对于 Android 10 及更高版本中的 arm64,二进制文件和库的可执行部分会映射到只执行(不可读取)内存,作为防范代码重用攻击的一种安全强化技术。通过在可执行内存中搜索已知函数,或通过在事先不了解内存布局的情况下构造小工具链,可以使用读取基元绕过地址空间布局随机化 (ASLR)。通过将可执行代码标记为不可读,读取基元将无法访问可执行内存,从而使各种攻击技术无法图谋不轨。这不仅可以提高 ASLR 的有效性,而且可以提高控制流完整性 (CFI),因为不会再向使用读取基元的攻击者透露有效目的地。
将代码设为不可读会导致故意或意外读入已标记为只执行的内存段抛出 SIGSEGV,并且代码为 SEGV_ACCERR。这可能是因为错误、漏洞、混合了代码的数据(例如文字池)或故意进行的内存自省导致的。
编译器会假设代码和数据不是混合在一起的,但是手写程序集会导致出现问题。在许多情况下,只需将常量移动到 .data 部分,即可解决这些问题。如果可执行代码段绝对有必要进行代码内省,则应首先调用 mprotect(2) 以将该代码标记为可读,然后在操作完成后重新将该代码标记为不可读。
Cause: execute-only (no-read) memory access error; likely due to data in .text.
您可以通过原因行将只执行内存违规与其他崩溃区分开来。
您可以使用 crasher xom 重现此类崩溃的实例。

我想你根本没看懂吧?没错因为我读了两遍都觉得啥也没懂。

然后我接下来看见了这个
https://source.android.com/devices/tech/debug/execute-only-memory
本来好像看到了希望,但是它有个warning。说这个在Android 11上移除了。

Important: XOM support has been removed in the upstream Linux kernel. XOM is only supported in Android 10 and has been removed in Android 11 and kernel changes removing it have been backported to 4.9, so the common kernel no longer supports XOM. More details on why XOM support was removed upstream can be found at PAN mitigation bypass

不出所料,即使disable execute-only at a module level 也完全不work。

// Android.mk
LOCAL_XOM := false

// Android.bp
cc_binary { // or other module types
   ...
   xom: false,
}

由于一开始没有可复现的设备,所以大多数情况都是根据log来猜测。终于在周末我拿到了可复现的设备。才发现这个问题最直接的复现方法是:

$adb root
$adb remount
$adb shell setenforce 0
——> 表示关闭selinux防火墙, 权限问题。
 
$adb shell
 
In the adb shell,
# stop
# setprop dalvik.vm.checkjni true
# setprop dalvik.vm.jniopts forcecopy
# start
 
(Device is rebooted)
$ adb shell getprop | grep "dalvik.vm.checkjni"[dalvik.vm.checkjni]: [true] (Check true)
——>启动JNI检查,调试用
 
$ adb shell getprop | grep "dalvik.vm.jniopts"[dalvik.vm.jniopts]: [forcecopy] (Check forcecopy)
——>检查数组越界

打开了CheckJNI来检查JNI error
https://android-developers.googleblog.com/2011/07/debugging-android-jni-with-checkjni.html
当然最重要的能够复现问题的点是dalvik.vm.jniopts。
如果是warnonly实际上你是get不到crash的。所以要改成forcecopy。
setprop dalvik.vm.jniopts warnonly
这个点就涉及到ART 垃圾回收(GC)了。https://source.android.com/devices/tech/dalvik/gc-debug

ART 有多个不同的 GC 方案,涉及运行不同的垃圾回收器。从 Android 8 (Oreo) 开始,默认方案是并发复制 (CC)。另一个 GC 方案是并发标记清除 (CMS)。

并发复制 (CC):

并发复制 GC 的一些主要特性包括:

  1. CC 支持使用名为“RegionTLAB”的触碰指针分配器。此分配器可以向每个应用线程分配一个线程本地分配缓冲区 (TLAB),这样,应用线程只需触碰“栈顶”指针,而无需任何同步操作,即可从其 TLAB 中将对象分配出去。
  2. CC 通过在不暂停应用线程的情况下并发复制对象来执行堆碎片整理。这是在读取屏障的帮助下实现的,读取屏障会拦截来自堆的引用读取,无需应用开发者进行任何干预。
  3. GC 只有一次很短的暂停,对于堆大小而言,该次暂停在时间上是一个常量。
  4. 在 Android 10 及更高版本中,CC 会扩展为分代 GC。它支持轻松回收存留期较短的对象,这类对象通常很快便会无法访问。这有助于提高 GC 吞吐量,并显著延迟执行全堆 GC 的需要。

这里有提到

使用 SIGQUIT 获取 GC 性能信息

如需获得应用的 GC 性能时序,请将 SIGQUIT 发送到已在运行的应用,或者在启动命令行程序时将 -XX:DumpGCPerformanceOnShutdown 传递给 dalvikvm。当应用获得 ANR 请求信号 (SIGQUIT) 时,会转储与其锁定、线程堆栈和 GC 性能相关的信息。
如需获得 GC 时序转储,请使用以下命令:
adb shell kill -S QUIT PID
这会在 /data/anr/ 中创建一个文件(名称中会包含日期和时间,例如 anr_2020-07-13-19-23-39-817)。此文件包含一些 ANR 转储信息以及 GC 时序。您可以通过搜索“Dumping cumulative Gc timings”(转储累计 GC 时序)来确定 GC 时序。这些时序会显示一些需要关注的内容,包括每个 GC 类型的阶段和暂停时间的直方图信息。暂停信息通常比较重要。
例如(本示例中显示平均暂停时间为 1.83 毫秒,该值应该足够低,在大多数应用中不会导致丢帧):
young concurrent copying paused: Sum: 5.491ms 99% C.I. 1.464ms-2.133ms Avg: 1.830ms Max: 2.133ms
挂起时间:
suspend all histogram: Sum: 1.513ms 99% C.I. 3us-546.560us Avg: 47.281us Max: 601us
总耗时和 GC 吞吐量:
Total time spent in GC: 502.251ms
Mean GC size throughput: 92MB/s
Mean GC object throughput: 1.54702e+06 objects/s
所以要查阅的话只要把/data/anr/anr_2020-07-13-19-23-39-81 pull出来就可以。

分析GC正确性问题的工具
造成 ART 内部崩溃的原因多种多样。读取或写入对象字段时发生崩溃可能表明堆损坏。如果 GC 在运行时崩溃,也可能是由堆损坏造成的。造成堆损坏的最常见原因是应用代码不正确。那CheckJNI就是用来调试的工具之一。

CheckJNI 是一种添加 JNI 检查来验证应用行为的模式;出于性能方面的原因,默认情况下不启用此类检查。此类检查将捕获一些可能会导致堆损坏的错误,如使用无效/过时的局部和全局引用。
adb shell setprop dalvik.vm.checkjni true

CheckJNI 的 forcecopy 模式对于检测超出数组区域末端的写入很有用。启用后,forcecopy 会促使数组访问 JNI 函数返回带有红色区域的副本。红色区域是返回的指针末端/始端的一个区域,该区域具有一个特殊值,该值在数组释放时得到验证。如果红色区域中的值与预期值不匹配,表明发生了缓冲区溢出或欠载。这会导致 CheckJNI 中止。
adb shell setprop dalvik.vm.jniopts forcecopy

举例来说,当写入超出从 GetPrimitiveArrayCritical 获取的数组的末端时,这就是 CheckJNI 应捕获的一个错误。此操作可能会损坏 Java 堆。如果写入发生在 CheckJNI 红色区域内,则在调用相应的 ReleasePrimitiveArrayCritical 时,CheckJNI 会捕获该问题。否则,写入会损坏 Java 堆中的某个随机对象,并且可能会导致将来发生 GC 崩溃。如果损坏的内存是引用字段,则 GC 可能会捕获错误并输出错误消息“Tried to mark not contained by any spaces”。

当 GC 尝试标记一个对象但无法找到其空间时,就会发生此错误。此检查失败后,GC 会遍历根,并尝试查看无效的对象是否为根。结果共有两个选项:对象为根或非根。

‍♀️不知道你看懂没。反正我看翻译是云里雾里。建议你们看英文去。

CheckJNI's forcecopy mode is useful for detecting writes past the end of array regions. When enabled, forcecopy causes the array access JNI functions to return copies with red zones. A red zone is a region at the end/start of the returned pointer that has a special value, which is verified when the array is released. If the values in the red zone don’t match what's expected, a buffer overrun or underrun occurred. This causes CheckJNI to abort.

这意思就是说forcecopy会标记数组,如果使用了被标记为release的数组则就会报错。

An example of an error that CheckJNI should catch is writing past the end of an array obtained from GetPrimitiveArrayCritical. This operation can corrupt the Java heap. If the write is within the CheckJNI red zone area, then CheckJNI catches the issue when the corresponding ReleasePrimitiveArrayCritical is called. Otherwise, the write corrupts some random object in the Java heap and can cause a future GC crash. If the corrupted memory is a reference field, then the GC may catch the error and print the error Tried to mark not contained by any spaces.

This error occurs when the GC attempts to mark an object that it can’t find a space for. After this check fails, the GC traverses the roots and tries to see if the invalid object is a root. From here, there are two options: The object is a root or a nonroot object.

尽管如此,我依然还是没有太理解的。
但是有一点很奇怪,参考JNI tips
https://developer.android.com/training/articles/perf-jni

    jbyte* data = env->GetByteArrayElements(array, NULL);
    if (data != NULL) {
        memcpy(buffer, data, len);
        env->ReleaseByteArrayElements(array, data, JNI_ABORT);
    }

This grabs the array, copies the first len byte elements out of it, and then releases the array. Depending upon the implementation, the Get call will either pin or copy the array contents. The code copies the data (for perhaps a second time), then calls Release; in this case JNI_ABORT ensures there's no chance of a third copy.
One can accomplish the same thing more simply:

env->GetByteArrayRegion(array, 0, len, buffer);

This has several advantages:

  • Requires one JNI call instead of 2, reducing overhead.
  • Doesn't require pinning or extra data copies.
  • Reduces the risk of programmer error — no risk of forgetting + to call Release after something fails.

理论上因为我们使用GetByteArrayRegion方法其实是不需要ReleaseByteArrayElements的,但是我们去掉Release方法又会发生泄漏。
(这里面要穿插一个知识点,是如果release的最后一位参数使用了JNI_COMMIT而不是0的话,那之前的code是会又memory leak的。)


image.png

呐,然后最后终于在这里找到了答案
https://zhuanlan.zhihu.com/p/148158311
作者举了个特别棒的int数组求和的例子

public native int sumArray(int[] array);
extern "C"
JNIEXPORT jint JNICALL
Java_com_xfhy_jnifirst_MainActivity_sumArray(JNIEnv *env, jobject thiz, jintArray array) {
    //数组求和
    int result = 0;

    //方式1  推荐使用
    jint arr_len = env->GetArrayLength(array);
    //动态申请数组
    jint *c_array = (jint *) malloc(arr_len * sizeof(jint));
    //初始化数组元素内容为0
    memset(c_array, 0, sizeof(jint) * arr_len);
    //将java数组的[0-arr_len)位置的元素拷贝到c_array数组中
    env->GetIntArrayRegion(array, 0, arr_len, c_array);
    for (int i = 0; i < arr_len; ++i) {
        result += c_array[i];
    }
    //动态申请的内存 必须释放
    free(c_array);

    return result;
}

C层拿到jintArray之后首先需要获取它的长度,然后动态申请一个数组(因为Java层传递过来的数组长度是不定的,所以这里需要动态申请C层数组),这个数组的元素是jint类型的.malloc是一个经常使用的拿来申请一块连续内存的函数,申请之后的内存是需要手动调用free释放的.然后就是调用GetIntArrayRegion函数将Java层数组拷贝到C层数组中,然后求和。

对应的需要release的:

extern "C"
JNIEXPORT jint JNICALL
Java_com_xfhy_jnifirst_MainActivity_sumArray(JNIEnv *env, jobject thiz, jintArray array) {
    //数组求和
    int result = 0;

    //方式2  
    //此种方式比较危险,GetIntArrayElements会直接获取数组元素指针,是可以直接对该数组元素进行修改的.
    jint *c_arr = env->GetIntArrayElements(array, NULL);
    if (c_arr == NULL) {
        return 0;
    }
    c_arr[0] = 15;
    jint len = env->GetArrayLength(array);
    for (int i = 0; i < len; ++i) {
        //result += *(c_arr + i); 写成这种形式,或者下面一行那种都行
        result += c_arr[i];
    }
    //有Get,一般就有Release
    env->ReleaseIntArrayElements(array, c_arr, 0);

    return result;
}

综上,就是用free(*ptr)。

{
    // jbyteArray data
    unsigned char *input = new unsigned char[len];
    env->GetByteArrayRegion(data, 0, len, reinterpret_cast(input));

    ... // actions
    free(input);
    // env->ReleaseByteArrayElements(data, reinterpret_cast(input), 0);
}

可谓艰苦卓绝地找到了解决方案。
然而其实这并不是很难的一个问题对吧。还是对NDK开发不够熟悉,对debug的方式也不太熟悉。
好在拿到了必现路径。
这些年的经验只证明了一件事。
只要问题能给我必现路径,那么我一定能给你解决方案。假使不能很快,则只能说还没太理解。给自己点时间。

对了,之前对于越界有提到一个解决方案是mprotect,不过我的尝试一直以失败告终。mprotect((inputBytes+allosize-pagesize), pagesize, PROT_READ) 总是返回-1。这让我百思不得其解。https://cs.android.com/ 源码看了看也还是没太get。就当成遗留问题先吧。

#include 
#include 

#include 
#include 
#include 
#include 
#include 
{
    int pagesize = sysconf(_SC_PAGE_SIZE);
    if (pagesize == -1) {
        LOG("sysconf");
    }
    int allosize = env->GetArrayLength(data);
    if (mprotect((input+allosize-pagesize), pagesize, PROT_READ) == -1) {
        LOG("mprotect");
        return hasObject;
    }
}

你可能感兴趣的:(NDK crash(signal 11 (SIGSEGV)) 分析历程)