各位观众,早上好/下午好/晚上好! 今天咱们来聊点刺激的:Node.js Native Addons 的逆向工程。 这玩意儿,说白了,就是用 C++ 给 Node.js 写插件。 听起来就很硬核是不是? 别怕,今天咱们尽量把它掰开了揉碎了讲,争取让大家听完之后,下次遇到这种玩意儿,不至于两眼一抹黑。
一、 啥是 Node.js Native Addons?
先来解决一下“我是谁?我从哪儿来?我要到哪儿去?” 的哲学问题。
Node.js 是个好东西,JavaScript 写起来也很舒服。 但是,JavaScript 有时候力不从心,比如:
- 性能瓶颈: 某些计算密集型任务,JS 跑起来慢如蜗牛。
- 硬件访问: 直接操作硬件? JS 比较困难。
- 现有 C/C++ 库: 已经有现成的 C/C++ 库,不想用 JS 重写一遍。
这时候,Native Addons 就派上用场了。 它可以让你用 C++ 写代码,然后像 JS 模块一样在 Node.js 中使用。 就像给 Node.js 插上了一双翅膀,一下子就变得高大上了。
二、 逆向工程? 为什么要逆向?
“正向”开发我们都懂,那“逆向”又是啥?
简单来说,逆向工程就是把一个已经编译好的东西,反过来分析它的原理和实现。 就像考古学家挖坟一样,一层一层地剥开,看看里面到底藏了什么宝贝。
为什么要逆向 Native Addons 呢? 原因有很多:
- 学习: 想知道别人是怎么用 C++ 写出高性能的 Node.js 模块的。
- 安全: 想看看这个模块有没有啥安全漏洞,会不会偷偷摸摸干坏事。
- 兼容: 想让一个旧的 Native Addon 在新的 Node.js 版本上跑起来。
- 破解: (此处省略一万字)。
总之,逆向工程是一项很有用的技能,可以让你更深入地了解软件的本质。
三、 逆向前的准备工作
工欲善其事,必先利其器。 在开始逆向之前,我们需要准备一些工具:
工具名称 | 作用 |
---|---|
Node.js |
运行和调试 Addon 的环境。 |
GDB |
Linux 下强大的调试器,可以用来单步调试 C++ 代码。 |
LLDB |
macOS 下的调试器,和 GDB 类似。 |
IDA Pro/Ghidra |
反汇编器和反编译器,可以将二进制代码转换成汇编代码或者 C 代码,方便分析。 |
objdump |
可以用来查看目标文件的各种信息,比如符号表、段信息等。 |
nm |
用来列出一个目标文件的符号表。 |
ltrace/strace |
跟踪进程的库函数调用和系统调用。 |
Node Inspector |
Node.js 的调试工具,可以用来调试 JavaScript 代码,也可以用来查看 Native Addon 的内存情况。 |
这些工具各有各的用处,我们需要根据具体情况选择合适的工具。 当然,熟练掌握这些工具需要时间,大家可以慢慢学习。
四、 Hello World 的逆向之旅
光说不练假把式。 咱们先从一个最简单的 Hello World 的 Native Addon 开始,来体验一下逆向的乐趣。
1. 编写 Hello World Addon
首先,创建一个 hello.cc
文件:
#include <node.h>
namespace demo {
using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Value;
void Method(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
args.GetReturnValue().Set(String::NewFromUtf8(isolate, "world").ToLocalChecked());
}
void Initialize(Local<Object> exports) {
NODE_SET_METHOD(exports, "hello", Method);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
} // namespace demo
然后,创建一个 binding.gyp
文件:
{
"targets": [
{
"target_name": "hello",
"sources": [ "hello.cc" ]
}
]
}
最后,使用 node-gyp
构建 Addon:
npm install -g node-gyp
node-gyp configure
node-gyp build
构建成功后,会在 build/Release
目录下生成一个 hello.node
文件。
2. 编写 JavaScript 代码调用 Addon
创建一个 index.js
文件:
const addon = require('./build/Release/hello');
console.log(addon.hello()); // Prints: 'world'
3. 逆向分析
现在,我们来逆向分析一下 hello.node
文件。
-
查看符号表:
使用
nm hello.node
命令可以查看hello.node
的符号表。 我们可以看到Method
和Initialize
等函数的符号。$ nm ./build/Release/hello.node 0000000000001000 T _ZN4demo6MethodEN2v825FunctionCallbackInfoINS1_5ValueEEE 0000000000001100 T _ZN4demo10InitializeEN2v85LocalINS1_6ObjectEEE U _ZN2v87Isolate11GetCurrentEv U _ZN2v86String11NewFromUtf8EPNS_7IsolateEPKcNS_13NewStringTypeE U _ZN2v85Value10ToStringEPNS_7LocalINS_6ContextEEE U _ZN2v813ReturnValueScope3SetENS_5LocalINS_5ValueEEE U _node_module_register U _Unwind_Resume U __cxa_allocate_exception U __cxa_begin_catch U __cxa_end_catch U __cxa_free_exception U __cxa_throw U _malloc U _free
-
反汇编:
使用
objdump -d hello.node
命令可以反汇编hello.node
文件。 我们可以看到Method
和Initialize
等函数的汇编代码。(或者使用 IDA Pro / Ghidra,它们的反编译功能更强大,可以直接将汇编代码转换成 C 代码)
0000000000001000 <_ZN4demo6MethodEN2v825FunctionCallbackInfoINS1_5ValueEEE>: 1000: 55 push %rbp 1001: 48 89 e5 mov %rsp,%rbp 1004: 48 83 ec 10 sub $0x10,%rsp 1008: 48 89 7d f8 mov %rdi,-0x8(%rbp) 100c: 48 8b 7d f8 mov -0x8(%rbp),%rdi 1010: e8 00 00 00 00 callq 1015 <_ZN4demo6MethodEN2v825FunctionCallbackInfoINS1_5ValueEEE+0x15> 1015: 48 8b 05 00 00 00 00 mov 0x0(%rip),%rax # 101c <_ZN4demo6MethodEN2v825FunctionCallbackInfoINS1_5ValueEEE+0x1c> 101c: 48 8b 00 mov (%rax),%rax 101f: ba 05 00 00 00 mov $0x5,%edx 1024: be 00 00 00 00 mov $0x0,%esi 1029: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi # 1030 <_ZN4demo6MethodEN2v825FunctionCallbackInfoINS1_5ValueEEE+0x30> 1030: e8 00 00 00 00 callq 1035 <_ZN4demo6MethodEN2v825FunctionCallbackInfoINS1_5ValueEEE+0x35> 1035: 48 8b 7d f8 mov -0x8(%rbp),%rdi 1039: e8 00 00 00 00 callq 103e <_ZN4demo6MethodEN2v825FunctionCallbackInfoINS1_5ValueEEE+0x3e> 103e: 90 nop 103f: c9 leaveq 1040: c3 retq
通过分析汇编代码,我们可以看到
Method
函数的实现: 它调用了 v8 的 API 来创建一个字符串 "world",然后将其作为返回值返回。 -
动态调试:
使用
gdb
或lldb
可以对hello.node
进行动态调试。 我们可以设置断点,单步执行代码,查看变量的值。- 启动 Node.js 进程,并使其加载
hello.node
。 - 使用
gdb
或lldb
attach 到 Node.js 进程。 - 在
Method
函数的入口处设置断点。 - 运行 JavaScript 代码,触发
Method
函数的调用。 - 单步执行代码,查看变量的值。
例如,使用
gdb
的步骤如下:gdb -p $(pidof node) (gdb) break hello.cc:10 (gdb) continue
- 启动 Node.js 进程,并使其加载
五、 进阶: 复杂 Addon 的逆向
Hello World 太简单了,没什么挑战性。 接下来,我们来挑战一个稍微复杂一点的 Addon。 比如,一个可以计算 MD5 值的 Addon。
1. 编写 MD5 Addon
创建一个 md5.cc
文件:
#include <node.h>
#include <node_buffer.h>
#include <openssl/md5.h>
namespace demo {
using v8::FunctionCallbackInfo;
using v8::Isolate;
using v8::Local;
using v8::Object;
using v8::String;
using v8::Value;
using v8::Exception;
using node::Buffer;
void MD5(const FunctionCallbackInfo<Value>& args) {
Isolate* isolate = args.GetIsolate();
// Check the number of arguments.
if (args.Length() != 1) {
// Throw an Error that is passed back to JavaScript
isolate->ThrowException(Exception::TypeError(
String::NewFromUtf8(isolate, "Wrong number of arguments").ToLocalChecked()));
return;
}
// Check the argument type
if (!Buffer::HasInstance(args[0])) {
isolate->ThrowException(Exception::TypeError(
String::NewFromUtf8(isolate, "Argument must be a buffer").ToLocalChecked()));
return;
}
Local<Object> bufferObj = args[0]->ToObject(isolate).ToLocalChecked();
char* bufferData = Buffer::Data(bufferObj);
size_t bufferLength = Buffer::Length(bufferObj);
unsigned char hash[MD5_DIGEST_LENGTH];
MD5_CTX md5Context;
MD5_Init(&md5Context);
MD5_Update(&md5Context, bufferData, bufferLength);
MD5_Final(hash, &md5Context);
char hexString[2 * MD5_DIGEST_LENGTH + 1];
for (int i = 0; i < MD5_DIGEST_LENGTH; i++) {
sprintf(&hexString[i * 2], "%02x", hash[i]);
}
hexString[2 * MD5_DIGEST_LENGTH] = '';
args.GetReturnValue().Set(String::NewFromUtf8(isolate, hexString).ToLocalChecked());
}
void Initialize(Local<Object> exports) {
NODE_SET_METHOD(exports, "md5", MD5);
}
NODE_MODULE(NODE_GYP_MODULE_NAME, Initialize)
} // namespace demo
然后,创建一个 binding.gyp
文件:
{
"targets": [
{
"target_name": "md5",
"sources": [ "md5.cc" ],
"include_dirs": [
"<!@(node -p "require('node-addon-api').include")"
],
"libraries": [
"-lssl",
"-lcrypto"
]
}
]
}
最后,使用 node-gyp
构建 Addon:
node-gyp configure
node-gyp build
2. 编写 JavaScript 代码调用 Addon
创建一个 index.js
文件:
const addon = require('./build/Release/md5');
const buffer = Buffer.from('hello world');
console.log(addon.md5(buffer)); // Prints: 'b10a8db164e0754105b7a99be72e3fe5'
3. 逆向分析
这个 Addon 比 Hello World 复杂多了,我们需要更高级的技巧。
-
分析依赖库:
md5.cc
中使用了 OpenSSL 库来计算 MD5 值。 我们需要了解 OpenSSL 的 API,才能更好地理解代码。 -
查看字符串:
使用
strings md5.node
命令可以查看md5.node
文件中的字符串。 我们可以看到一些有用的信息,比如错误信息、函数名等。$ strings ./build/Release/md5.node Wrong number of arguments Argument must be a buffer md5
-
反编译:
使用 IDA Pro 或 Ghidra 可以将
MD5
函数反编译成 C 代码。 这样可以更方便地理解代码的逻辑。反编译后的代码可能如下所示 (简化版):
const char* MD5(char* bufferData, size_t bufferLength) { unsigned char hash[MD5_DIGEST_LENGTH]; MD5_CTX md5Context; MD5_Init(&md5Context); MD5_Update(&md5Context, bufferData, bufferLength); MD5_Final(hash, &md5Context); char hexString[2 * MD5_DIGEST_LENGTH + 1]; for (int i = 0; i < MD5_DIGEST_LENGTH; i++) { sprintf(&hexString[i * 2], "%02x", hash[i]); } hexString[2 * MD5_DIGEST_LENGTH] = ''; return hexString; }
通过反编译的代码,我们可以清晰地看到 MD5 值的计算过程。
-
动态跟踪:
使用
ltrace
或strace
可以跟踪md5.node
的库函数调用和系统调用。 我们可以看到MD5_Init
、MD5_Update
、MD5_Final
等函数的调用。使用
ltrace
的命令如下:ltrace -p $(pidof node)
通过动态跟踪,我们可以了解 Addon 内部的运作机制。
六、 常见问题与技巧
在逆向 Native Addons 的过程中,我们可能会遇到各种各样的问题。 这里总结一些常见的问题和技巧:
-
符号表被 stripped:
有些 Addon 在编译时会去掉符号表,这会给逆向带来很大的困难。 可以使用一些工具来尝试恢复符号表,比如
unstrip
。 -
代码被混淆:
有些 Addon 会使用代码混淆技术来增加逆向的难度。 可以使用一些反混淆工具来尝试还原代码。
-
动态加载:
有些 Addon 会动态加载其他的库,这会增加逆向的复杂度。 可以使用
ltrace
或strace
来跟踪动态加载的过程。 -
调试信息:
在编译 Addon 时,可以添加调试信息。 这会给逆向带来很大的帮助。 可以使用
-g
选项来添加调试信息。 -
多平台兼容性:
Native Addons 需要针对不同的平台进行编译。 逆向时需要注意平台的差异。
七、 总结
Node.js Native Addons 的逆向工程是一项充满挑战性的任务。 需要掌握一定的 C++ 知识、汇编知识和调试技巧。
但是,只要我们掌握了正确的方法和工具,就可以揭开 Native Addons 的神秘面纱,了解其内部的运作机制。
希望今天的讲座能对大家有所帮助。 谢谢大家!
补充说明:
- 实际的逆向工程可能会更加复杂,需要根据具体情况进行分析。
- 逆向工程可能涉及到法律问题,请务必遵守相关法律法规。
- 本文只是一个入门教程,更深入的学习需要大家自己去探索。
祝大家逆向愉快!