ps:其实d2过去很长一段时间了,但是中间一直忙于业务没时间梳理这个分享的内容~
首先,我们考虑下,就是怎么逆向工程从v8进程中复活node。其实在不熟悉整个流程的时候,我们可以简化条件,来加深自己对内容的理解,可以先考虑系统是如何从进程中复活一个程序,因为node运行的进程对系统来说与其他进程其实无异,这里就要引入coredump的概念了
1.进程的运行与coredump
,当我们运行一个可执行文件时,会起一个对应的进程。某个时刻,这个进程碰到了abort而终止。此时,如果你设置好了对应的应对方式,系统根据你的设置写入文件到你的本地磁盘里。除此之外,在没有发生异常的时候可以通过gcore等方式把进程运行时的镜像内容写入磁盘中以便于后续调试。linux上常用的是coredump,会把当时的内存状态以及一些寄存器内容写入磁盘。windows中常用的是minidump,minidump只记载对应的某一个异常情况,比如说chrome报错了,就记录chrome的错误信息。除此之外还有很多工具是应用程序自己定位错误的工具,比如node-report,他会生成定制的文件,用来记录对应工具的状态。事后可以运用工具来解析当时你程序的运行状态
2.Core Dump analysis 举例
举一个coredump analysis的例子,假设有一个虚构的vm.cc用来读取js文件,这个vm在linux上可以编译成一个可执行文件。linux的可执行文件的格式一般都是elf,这个elf上会有一个专门用来记录debugInfo的section(这个debugInfo格式一般都dwarf)。vm运行过程中abort了,根据你的设置coredump的时候记录一份代码。
一般通过lldb或者gdb来进行分析你的可执行文件的elf和记录core dump时内存情况的elf,得到具体的报错内容。但是如我们所见,这个报错内容中有一些不可读的信息,这些就是我们js抛出的错误信息,因为我们动态语言是用我们的vm进行执行的,它不一定会把相应的debugInfo写入,或者写入也无法被lldb和gdb进行解读,所以会呈现为不可读状态,会导致我们无法快速定位到错误信息,那么如何去让这个错误信息变成可读状态呢?
一般情况下,都是通过在可执行文件加入一额外的hock/additional Metadata,或者装一些插件,插入一些lldb或gdb可理解的内容,用来还原当时场景
那么就引入了这次分享的主角llnode,llnode是基于ldb进行开发的。llnode是主要面对v8的一个插件,除了nodejs以外,只要是基于v8开发的引擎其实都可以借助llnode进行调试
3.llnode的运行过程简单介绍
我们都知道,v8引擎是node源码中很小的一块,v8下面有个脚本,这个脚本会扫描v8的头文件,输出一个debug-support的.cc文件,它会把全部metadata放入这个文件中,输出成全局变量,编译的时候会放在你的elf文件中,可以让debug找得到,以便于嵌入一个埋点。实际过程中,node程序运行了,拿到了生产coredump的可执行文件与coredump文件,然后把这两个文件给llnode,就能解读对应内存块,解读出你的文件中原本不被是别的js代码
接下来我们来了解下lldb是如何从内存中获取对应的js信息的
4.从原始内存中重建js值
假设有一个内存块,我们获取他的内存地址去解析这块内存上的内容。因为我们都知道v8的所有内存都是对齐。一个64位机器上,一个内存地址后面就是64位的字段,所以可以先查看64位的最后一个字段,如果最后一位是0,代表是一个小整数,那么直接获取前32位内容就可以了,按照整数来解读。如果最后一位是1,那么代表是一个地址,我们需要取前面63位,根据这个地址去找到另一个内存地址,那到第二个地址后就可以知道heapObject是怎样的布局,v8上绝大部分的对象一般都是用heapObject布局。第二个内存地址第一位一般来说都是个map pointer,根据这个指针可以去读取出实际的map是怎么构成的,读取真是map的常量得知这个map中存有一个offset叫做instancetype,然后可以根据instancetype得知这个对象具体是什么类型的
拿一个简单的例子为例--js的string模型,不包含中文字符且底层表示为序列的string模型。假设这样一个字符串,你拿到了对应的map,map字后面两位分别是是字符串的hash和length,然后根据map去查看他的instanceType ,instaceType是于一个bit field ,去读取这个bit field中代表encoding的byte,如果这个byte是1,代表是string模型下面的字符都是一个字节的,没有中文字符这些,然后读取representation对应的byte ,如果这个byte为0,代表这个模型是序列的,不存在树形结构。得知解读方式后就可以解读这个模型,知道后面的都是字符,encoding得知1个位置代表一个字符,就可以一个个读取直接返回对应的字符串内容。
在了解了lldb如何从内存中获取信息后再了解下v8是如何处理js调用函数的流程的
5. V8还原帧上的js调用函数流程
首先debugger unwind the stack(展开当前的堆栈), 会有很多 frame pointer(帧指针) 告诉你每帧对应的布局是怎么样的, 从上到下 分别是context(js当前作用域上下文),jsfunction,然后是各种framepointer,之后是返回地址, 这个时候读一下 frame pointer 前面的64位字段,指向的是jsfunction的地址,jsfunction对象中也存在一个字段 sharedFunctionInfo,读取shareFunctionInfo,里面有个script字段,script字段后面还有一个formal params count的字段,代表调用函数有多少个参数, 可以按照layout继续解读存放在frame上的函数名上参数所对应的数值,function name,对应名字,script 里面有源代码,shared name代表文件名, offset代表行列的变化,
根据以上的信息,可以还原出你出错的时候,对应是调用什么function引起的,调用的时候的参数等等.
6.安装与api调用方式
npm install llnode --llnode_build_addon=true
复制代码
llnode_build_addon 暴露的api, 会把对应的api暴露出来,可以把故障的可执行文件和coredump结合起来,把js的值重新还原到js中,写入nodejs里的脚本去
api使用方式,
- 可以用process threads看所有的线程,每一个线程里的信息。可以去扫描所有进程,当你知道你是因为内存泄漏挂掉的时候,
const llnode =require('llnode').fromCoredump('/path/to/core','/path/to/node');
const process =llnode.getProcessObject();
console.log(`Process ${process.pid}: ${process.state}`);
process.threads.forEach((thread)=>{
console.log(`Thread ${thread.threadId}`);
thread.frames.forEach((frame,index)=>{
console.log(`#${index} ${frame.function}`)
})
})
复制代码
- 可以扫描进程得知coredump的时候哪个进程最多,然后可以根据打印出来的class去找寻js中泄露的对象
llnode.getHeapTypes().forEach((type)=>{
console.log(`${type.typeName}: ${type.totalSize}`)
console.log(`${type.instanceCount} instances`)
for( const instance of type.instances){
console.log(`0x${instance.address} instance.value`)
}
})
复制代码
因为是逆向工程,所以严重依赖当前版本的执行流程,所以每次v8换一个版本的时候,基本都需要针对新版本v8进行变动,但是nodejs的每个LTS的版本内部不会对v8进行大升级的。
个人感想
- 听分享的过程中深刻感觉到自己很多知识的缺失,很多内容没有了解对应的前置内容时,会完全无法理解
- 参考资料中的案发现场还原有比较完整的测试用例,有兴趣的同学可以尝试下
参考资料
- 第十三届 D2 前端技术论坛精彩回顾 -- 张秋怡 - Bringing JavaScript Back to Life.pdf
- Node 案发现场揭秘 —— Coredump 还原线上异常
- 前置知识点 v8引擎的编译方式与内存存储方式 coredump的基础知识 lldb与gdb的作用