卡死 App 的神秘字符串,究竟是何方神圣(上)
小编导读
在一个夜黑风高的晚上,微信上收到了一段神秘代码,打开聊天窗口,准备仔细查看这是啥的时候,发现微信卡死了。这段神秘代码为什么会导致 App 卡死呢?我们来一探究竟。
文 / aWang
编辑 / finn
本文共3480字,预计阅读时间9分钟。
#
背景介绍
卡死 App 的神秘代码
#
“卡死”是怎么产生的
frida 使用介绍
frida hook 效果及分析
为什么红米 5.0.2 没有效果
#
尝试问题复现
死循环出现的场景
#
卡死 App 字符串的神秘之处
内容将在本文的下篇中推送,请持续关注
#
如何规避/处理该场景下的ANR
内容将在本文的下篇中推送,请持续关注
01
事情是这样的
我正在热火朝天地吃着面的时候呢,老大在群里 @ 了我一下,吓得我碗都掉面里了。
于是无知的我为了体验一下这个 Bug,就把神秘代码复制直接发群里了。发完之后,点了点,戳了戳,发现没卡死呀。
但是等了半响,突然察觉到事情有些不太对劲。
对不起🧎♂️
02
“卡死”是怎么产生的
cue 完之后我们来看一下这个问题。
我在 Pixel2 (8.1.0) 设备上用微信 8.0.7 重现了这个问题,但是之前在 Redmi Note 3 (5.0.2) 设备上用微信 8.0.3 版本却并没有出现卡死的情况。实际上,几乎所有版本的微信都有这个问题,之所以在红米手机上没有出现,是与其系统版本有关系,这一点会在下文中详细说明。
*注意后续分析过程的所有源码均是基于 android-8.1.0_r39 进行的,因为我手里这台设备刚好是 8.1.0 的系统,这样分析过程中的行号能够更好地匹配上。
接着,从设备中取出 trace 文件进行分析,这里有几种方式:
○ 如果设备 root 了,直接从 /data/anr 目录获取即可。
○ 如果设备没有 root,可以通过 adb bugreport 获取,在解压后的 FS/data/anr 目录中。
通过 trace 文件,我们大致可以做出猜测:ANR 的发生是因为在 nextSpanTransition 的上层调用上出现了死循环。
于是我们可以一层一层往上找代码。
在 android.text.TextLine 的 getOffsetBeforeAfter[1] 方法中发现了一个 while true,它来自 getOffsetToLeftRightOf 方法第 522 行[2]的调用。
代码看到这里,我们并不知道具体是什么样的参数导致了循环,这个时候有两个方式可以进行调试:一个是用 Android Studio 直接 Debug;另外一个是用 frida 进行动态 Hook。
1、AS 直接 Debug 的优势是可以单步调试每一个过程,可以知道中间变量的值。但是也有几个缺点 (其实说起来并不算是缺点,只是需要满足一些条件):
○ 一个是如果目标应用不是 debuggable 的,那么无法调试(如果是 root 设备,可以通过 adb shell getprop | grep debuggable 查看全局 debuggable 是否开启,若未开启则通过 setprop 命令开启即可)。
○ 另外,如果调试的源码版本和设备系统版本对应不上,调试代码时就会出现代码行对应不上的问题,这会对分析问题造成一些困扰。因此,最好的办法就是自己通过 AOSP 编译一个 ROM,这样就能完美的匹配上了。
2、除了 AS 直接 Debug 的方式,这里将给大家介绍 frida 动态 Hook 的方式。其实在一开始就可以用 AS 进行调试,但因为平时经常使用 frida,就下意识选择了它来分析这个问题。在一些无法调试或者 AS 调试不方便的场景 (比如 system_server ),frida 就会体现出它的优势。
frida 使用介绍
frida 是一款动态 hook 的框架/工具,基本上是全平台的。我们可以使用 JavaScript 来编写 hook 脚本。编写起来也很简单,如下图所示,我们可以 hook 比如 getOffsetBeforeAfter 方法,拿到其入参和返回值 (如果需要的话也可以修改);当然也可以调用任何 Java 方法 (直接或者间接通过反射的方式)。同时我们还可以想办法拿到当前方法的调用堆栈,分析调用流程会更方便,可谓是不可多得的动态分析神器。
frida 的基本使用流程如下:
○ 需要准备一台 root 设备
○ 然后从 GitHub[3] 选择对应的架构下载 frida-sever
○ 将 frida-sever push 推送到设备上: adb push frida-server /data/local/tmp/frida-server
○ 赋予执行权限: adb shell chmod 777 /data/local/tmp/frida-server
○ 启动 server: adb shell /data/local/tmp/frida-server
○ 在 python 环境下安装 frida tools: pip install frida
○ 编写 hook 脚本,比如 anr.hook.js
○ 启动 App,加载脚本,开始 hook: frida -U pkgName -l anr.hook.js (pkgName 为 App 包名,比如快手 com.smile.gifmaker,l 参数表示需要 load 的脚本所在路径)
整个流程非常简单便捷,更多的 frida 内置函数可以在官方文档[4]查看。
frida hook 效果及分析
我们通过 frida hook 捕获到了方法的参数,如下图所示。
nextSpanTransition 的入参数为 81 和 81 (另外其返回值为 81,这里没有打印)。
另外,通过 getOffsetBeforeAfter 的入参可以一点一点推断相关变量的值:
○ 通过 hook getOffsetBeforeAfter 得到
offset = 0
after = true
runStart = 0
runLimit = 0
○ 通过 hook nextSpanTransition 得到
limit = 81
mStart + spanStart = 81
○ 推导变量
spanStart = runStart => 0
mStart => 81
limit = mStart + runLimit => 81
target = after ? offset + 1 : offset => 1
spanLimit = 81 - mStart => 0
所以,spanLimit >= target (0 >= 1) 永远无法满足,死循环就产生了。
关于 mSpanned.nextSpanTransition 的返回值,通过 SpannableStringInternal[5] 的代码也能够分析得出:如果 start 到 limit 之间没有 Span 数据的话,其返回值也就是 limit。对于上面的场景,也即 81。
看到这里,我们可能会认为调用 getOffsetBeforeAfter 方法好像很容易产生死循环,但实际上触发场景与外层方法 getOffsetToLeftRightOf 传入的参数有关,这个在后文的分析过程中会讲到。
为什么红米 5.0.2 没有效果
回到前面的问题,为什么我一开始没有复现呢?
这主要源于 getOffsetForHorizontal 方法的实现,Android 7.x 之前的版本( 包括 5.x ) 在 getOffsetForHorizontal 方法里面是并没有调用 Layout.getOffsetToLeftRightOf 的,所以不会触发上面分析的流程,也就不会触发死循环。
从代码里面可以看到 max 取的是 lineEndOffset - 1 (行结尾),而新版本安卓则针对 getLineCount - 1 != line (非最后一行) 场景做了区分处理,使用 getOffsetToLeftRightOf 来获取。
03
尝试复现一下
现在,我们再回过头来看一下卡死是什么场景导致的。
仔细查看堆栈,我们会发现是从 onTouch 之后触发对系统方法 getOffsetForHorizontal 的调用。那是否这个问题之前就有人遇到过呢?本着这个思路我们发现了一个来自 Stack Overflow 的一个问题 “android - Can I disable the scrolling in TextView when using LinkMovementMethod”[6],这并不是 ANR,而是为了解决 Span 点击的准确性问题。
同样的,包括快手在内的很多 App 应该都有使用这一段代码的逻辑:先通过调用 Layout.getLineForVertical 获取 Touch 所在的行,然后调用 Layout.getOffsetForHorizontal 获取 Touch 所在的文本的偏移,之后调用 getSpans 查看在这点击区域是否有 Span,如果有那么就触发 Span 的相关逻辑。
大家可以去搜一搜自己的项目里面有没有和上面解决方案相似的代码或者 “getOffsetForHorizontal” 这个方法,如果用到了,可能要考虑下是否需要规避。
为了简化这个问题的触发场景,便于我们后续分析,我们可以尝试写一个 Demo 来复现这个问题。
复现很简单,如下代码只要触摸 TextView 就会出现卡死。我们把神秘字符串作为 Spannable 加载,配合 LlinkMovementMethodOverride 即可,其中 LlinkMovementMethodOverride 来自上面提到的 StackOverflow。
所以是怎么导致的死循环?
到目前为止,我们仍然不知道死循环是如何导致的,我们只找到了出现 ANR 的点,以及触发场景,但是并没有解释清楚为什么这段字符串会导致死循环。
从“神秘代码”的构造来看,似乎是系统处理从右到左类型 Unicode 字符与 Span 相关逻辑时的 Bug (从参数 runIsRtl 也能猜到一些可能与此有关)。
这就需要一层一层去反推参数,以及理解 Layout 和 TextLine 关于 getOffsetForHorizontal 的逻辑。不管作者他是正向推导出的还是反向推导出的,肯定是花了一定心思的。
其实在几年前,微信也出现过“15。。。。。。。。”导致大规模卡死的问题[7],这个问题是因为处理正则表达式不当导致的卡死,也是死循环。所以我们针对 while (true) 的场景一定要设计好它的退出条件,不然很容易就 GG 啦。
在内部和大家分享了这个案例之后,这件事情差不多到这里就结束了。但是我们其实还有很多问题没弄清楚,为什么一定是这一段字符串?它有什么特点?它是通过什么原理导致的死循环?我们可以通过什么样的方式去避免等等。
本着对待问题谨慎的态度,我们还是需要继续深入探索一下这个问题的本质。限于推送篇幅,我们将在下篇中和大家一一讲解。
最后,为老铁们附上导致 App 卡死的神秘字符串,大家可以尝试一下。(PS: 如果显示该段文本的设备字体没有包含其中某些字符,可能会展示为黑块空框或者问号)
.ۦོ͢✘͢͢ۦོ͢⇣͢✰͢↬ÂмRØ^^O̷ ꦿ⃕O̷↬ۦོ͢✰͢⇣͢✘͢͢ ✘͢͢ۦོ͢✘͢͢ۦོ͢⇣͢✰͢↬^O̷ ꦿ⃕O̷↬ۦོ͢✰͢⇣͢✘͢͢ ✘͢͢ۦོ͢✘͢͢ۦོ͢⇣͢✰͢↬^^O̷ ꦿ⃕O̷↬ۦོ͢✰͢⇣͢✘͢͢ ✘͢͢ۦོ͢✘͢͢ۦۦོ͢✘͢͢ۦོ͢⇣͢✰͢↬^^O̷ ꦿ⃕O̷↬ۦོ͢✰͢⇣͢✘͢͢
------------------------------------
相关链接
[1]https://android.googlesource.com/platform/frameworks/base/+/android-8.1.0_r39/core/java/android/text/TextLine.java#640
[2]https://android.googlesource.com/platform/frameworks/base/+/android-8.1.0_r39/core/java/android/text/TextLine.java#552
[3]https://github.com/frida/frida/releases
[4]https://frida.re/docs/javascript-api/
[5]https://android.googlesource.com/platform/frameworks/base/+/android-8.1.0_r39/core/java/android/text/SpannableStringInternal.java#371
[6]https://stackoverflow.com/questions/14579785/can-i-disable-the-scrolling-in-textview-when-using-linkmovementmethod
[7]https://www.zhihu.com/question/65828771
”
欢迎加入
快手主站技术部客户端团队由业界资深的移动端技术专家组成,通过领先的移动技术深耕工程架构、研发工具、动态化、数据治理等多个垂直领域,积极探索创新技术,为亿万用户打造极致体验。团队自2011年成立以来全面赋能快手生态,已经建立起业内领先的大前端技术体系,支撑快手在国内外的亿万用户。
在这里你可以获得:
提升架构设计能力和代码质量
通过大数据解决用户痛点的能力
持续优化业务架构、挑战高效研发效能
和行业大牛并肩作战
我们期待你的加入!请发简历到:
app-eng-hr@kuaishou.com
”