Elkeid Golang RASP

简介

在以往的 RASP 解决方案中,部署方式通常需要业务参与,修改相关配置或是启动参数,这也就造成了 RASP 部署困难的窘境。更有甚者,由于 Golang 编译型语言的特性,多数 RASP 只能被迫选择在编译期集成进去,以降低技术实现成本,但这无疑又间接加大了部署推广的难度。
我始终认为限制 RASP 发展与推广的是部署,而并非是技术难度,许多厂商在各语言的技术实现上都有大同小异的成熟方案。例如使用 JVMInstrumentation 功能动态修改 bytecode,可以在虚拟机功能的基础上实现稳定的运行时防护。
所以在 Elkeid RASP 的项目初期,团队就敲定了动态注入的部署方式,一切都朝着降低部署难度的目标靠拢。

原理

目前 Elkeid RASP 开源了 JVMPythonNode 以及 Golang 四种语言的运行时保护功能,四种语言均支持对已存在的进程动态注入防护代码。其中 JVMNode 依赖于虚拟机提供的机制,运行稳定,而 PythonGolang 则需要利用 ptrace 在进程层面上做注入。

进程注入

为了实现对 Python 以及 Golang 进程的动态防护,先不考虑运行时层面的代码篡改,我们至少需要一个能够在 Linux 任意进程空间内执行任意代码的工具。但是很可惜,Linux 没有类似于 CreateRemoteThread 的接口。
大多数的代码注入,都是使用 ptrace 篡改进程执行流程,调用 dlopen 加载动态库。而且大多数项目都会指出,该方式的不稳定性可能会导致进程永久卡住。因为 dlopen 底层会调用 malloc,而在 glibc 的官方文档中指明了 malloc 是不可重入函数。
在研究过程中,我发现了 mandibule 这个项目,它另辟蹊径地编写了一个 ELF Loader,再使用 ptrace 让该 ELF Loader 在目标进程内执行,加载一个全新的程序,执行完成后恢复主线程的寄存器。
由于作者已经放弃维护,而且我自己在使用过程中,发现了项目一些设计上的缺陷以及代码 bug。于是我借鉴了该思路,开发了 pangolin 这个工具,它可以在任意进程内临时运行另一个程序,细节可以看相关的 blog

Inline Hook

借助 pangolin,我们可以在一个 Golang 的进程中执行任意代码,我们甚至可以篡改可执行段的机器码,很轻松地便可以对某个函数进行 Inline Hook。例如我们想对 Golang 的命令执行函数 exec.Command 进行 Inline Hook,那么可以在进程注入期间修改函数 os/exec.Command 的开头指令,使其执行时先跳转到我们编写的函数中。在我们自定义的函数中,便可以获取该函数调用时的入参以及调用栈,再通过某种通信方式传输出去,一个简单的 RASP 模型便完成了。
那么要完成该流程,我们需要一些先决条件:

  • 在去除掉 ELF 符号信息的情况下,如何获取 Golang 的符号信息,以确定函数的地址,完成对函数的 Inline Hook 操作。
  • Golang 如何进行函数调用,通过寄存器亦或是栈,我们又该如何读取函数的入参。
  • 如何获取 Golang 当前函数的 Stack Frame 长度,用于定位上一层函数的返回地址,完成调用栈回溯。

Golang Runtime Symbol Information

在去除了 ELF 符号信息后,一个编译好的 Golang 程序还是可以正确地执行 debug.PrintStack 函数,那么便可以证明 Golang 内部必然还存在一个符号表。根据官方文档 Go 1.2 Runtime Symbol Information 的介绍,Golang 从 1.2 之后的版本内置了符号信息,对于 ELF 格式来说,通常放置在 .gopclntab 这个 section
这些符号信息不仅包含了函数名称、函数地址范围以及函数栈帧长度信息,甚至还有相关的代码文件名,以及行号等源码信息。根据文档记录的信息格式,我们可以很轻松地解析出一个 Golang 二进制程序的符号表。

Golang build info

对于 Golang 1.13 以上编译出的二进制,可以使用 go version 命令查看编译时的 Golang 版本,由此说明二进制中内嵌了相关的编译信息。对于 ELF 格式来说,编译信息存放在 .go.buildinfo section,其中包含 Golang 版本号以及三方依赖库列表。值得注意的是,buildinfo 的格式在 Golang 1.18 版本发生了改变,弃用了数据指针,相关解析代码可查看官方仓库

Golang internal ABI

接下来我们需要了解 Golang 编译出的机器码,是如何进行函数调用的,以及 Golang 的结构体在内存中是如何存放的,了解这些之后我们才能正确地取出函数入参。

memory layout

Golang 包含的内置类型描述,可以在官方文档 The Go Programming Language Specification 中找到。对于数值类型,Golang 规范了类型的内存占用大小,而字节对齐则随 CPU 架构不同而变化。对于复合类型,例如 stringslice 以及 map 等,内存占用大小由组成的基础类型及其字节对齐决定,而该复合类型的字节对齐由组成类型中最大的字节对齐决定。
文档中并未描述 string 等内置类型的底层内存排布,但是我们可以从一些文章,亦或是 CGO 生成的头文件中一窥究竟。
以下是我使用 cppx64 架构下 Golang 类型的描述,代码可以在 Elkeid 官方仓库中找到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
namespace go {
typedef signed char Int8;
typedef unsigned char Uint8;
typedef short Int16;
typedef unsigned short Uint16;
typedef int Int32;
typedef unsigned int Uint32;
typedef long long Int64;
typedef unsigned long long Uint64;
typedef Int64 Int;
typedef Uint64 Uint;
typedef __SIZE_TYPE__ Uintptr;
typedef float Float32;
typedef double Float64;
typedef float _Complex Complex64;
typedef double _Complex Complex128;

struct interface {
void *t;
void *v;
};

struct string {
const char *data;
ptrdiff_t length;
};

template<typename T>
struct slice {
T *values;
Int count;
Int capacity;
};
}

可以看到 string 类型由两个字段组成,数据指针加上字符串长度,在内存中总共占用 16 字节。string 的内存对齐则由这两个字段决定,即 align(string) = max(align(const char *), align(ptrdiff_t))
对于 int32 这些 Golang 的基础数值类型来说,其字节对齐与 cpp 默认的对齐一致。

Type 64-bit 32-bit
Size Align Size Align
bool, uint8, int8 1 1 1 1
uint16, int16 2 2 2 2
uint32, int32 4 4 4 4
uint64, int64 8 8 8 4
int, uint 8 8 4 4
float32 4 4 4 4
float64 8 8 8 4
complex64 8 4 8 4
complex128 16 8 16 4
uintptr, *T, unsafe.Pointer 8 8 4 4

但是 Golang 并不确保这些类型的字节对齐不变,官方似乎正在考虑改变 x86int64 的字节对齐。现在我们已经了解了 Golang 类型的内存排布,那么对于任意入参的函数调用,我们都能准确的从内存中取出数据。现在剩下的问题便是函数调用发生时,参数将会存放在何处?

stack-based calling conventions

Golang 在 1.17 版本之前的函数调用中,参数与结果均存放在栈上。但由于栈上频繁的内存操作影响了运行性能,所以社区草拟了基于寄存器的调用约定方案,并在 1.17 版本后切换到该调用约定。
我们先了解 Golang 最原始的 ABI0,也就是基于栈的调用约定,细节描述可以从文档 A Quick Guide to Go’s Assembler 找到。在函数调用发生时,调用者需要将参数以及返回值,从低地址向高地址依次排列在栈顶。
例如在调用函数 func A(a int32, b string) (int32, error) 时,我们需要按下列排布存放参数与返回值:

+------------------------------+
| 2nd result error.v           |
| 2nd result error.t           |
| 1st result int32             |
| <pointer-sized alignment>    |
| b string.length              |
| b string.data                |
| a int32                      |
+------------------------------+ ↓ stack pointer

先放入 4 字节的参数 a,接着放入 string 类型的参数 b。由于 string 类型的字节对齐是 8,而此时的地址为 sp + 4,所以需要填充 4 字节的空白区域,从 sp + 8 开始放置 string 的数据。参数存放完成后,如果此时的地址没有按指针大小对齐,则需要填充空白字节。例如在 amd64 架构上,最后一个 int32 的参数放置于地址 0x40000,占用 4 字节大小,那么我们需要再填充 4 字节空白数据,使得返回值存放地址为 0x40008,按当前架构的指针大小 8 对齐。
我们接着放入第一个 int32 的返回值,而第二个返回值类型为 error,实际上就是 interface 类型。由上一小结可知,interface 类型占用 16 字节,按 8 字节对齐,所以我们填充 4 字节后,放入 error 结构体。当然,对于返回值而言,我们并不会真正地写入数据,而是预留内存空间以供被调用者写入。

register-based calling conventions

对于基于寄存器的调用约定,调用者需要先尝试将参数放置于寄存器中。如果结构体太大,或是结构体中包含 Non-trivial arrays 类型成员导致无法存放,则会转而放置于栈上。对于 amd64 架构,Golang 使用X0X14 寄存器存放浮点数数据,而对于整数数值,则使用以下 9 个整数寄存器存放:

RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11

对于数值类型参数,我们可以直接将参数一一对应到寄存器中。而对于结构体类型,我们需要将结构体拆解成多个基础数值类型,然后进行对应放置。如果一个结构体拆解后,需要占用的寄存器数超过了剩余的寄存器数,则该整个结构体都只能放置于栈上。该部分细节繁杂,本文不作赘述,细节请看文档 Go internal ABI specification

实现

Runtime conflict

有了上述理论支持后,我们现在可以着手编写钩子函数了。在钩子函数执行过程中,不能随意篡改堆栈上的数据,执行完成后需要恢复所有寄存器,并跳转到原函数继续执行。需要注意的是,我们必须时刻记住,执行钩子函数的是 Golang 的线程,那么就存在以下两个问题:

  • Golang 为线程分配的栈空间很小,钩子函数如果使用过度会导致 Segmentation fault
  • Golang 的线程中执行时,我们无法正常调用 glibc 函数,例如 malloc 依赖于 fs 寄存器指向的 TLS 结构,以保证线程安全,但 fs 在 1.17 版本以下的 Golang 线程中指向全局 G

为了解决第一个问题,我们需要在钩子函数的入口处,申请一块足够大的内存替换当前栈。而对于第二点,我们只能使用freestanding 代码及 syscall 来完成参数读取与栈回溯操作。为了更好地解耦与复用,于是我开发了一个不依赖于 glibc 的小型 c-runtime,包含内联汇编编写的 syscall 以及必要的标准库函数。我们可以安全地在 Golang 线程中调用 c-runtime 中的任何函数,例如使用底层是无锁环形缓冲区和 mmap syscallz_malloc 来分配堆空间。

钩子函数

下面是使用内联汇编编写的钩子函数 wrapper,可以通用地进行栈替换、寄存器备份以及函数跳转:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
asm volatile(
"mov $1, %%r12;"
"mov %%rsp, %%r13;"
"add $8, %%r13;"
"and $15, %%r13;"
"sub $16, %%rsp;"
"movdqu %%xmm14, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm13, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm12, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm11, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm10, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm9, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm8, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm7, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm6, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm5, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm4, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm3, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm2, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm1, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm0, (%%rsp);"
"push %%r11;"
"push %%r10;"
"push %%r9;"
"push %%r8;"
"push %%rsi;"
"push %%rdi;"
"push %%rcx;"
"push %%rbx;"
"push %%rax;"
"sub %%r13, %%rsp;"
"mov %0, %%rdi;"
"call z_malloc;"
"cmp $0, %%rax;"
"je end_%=;"
"mov %%rsp, %%rdi;"
"mov %%rax, %%rsp;"
"add %0, %%rsp;"
"push %%rax;"
"push %%rdi;"
"add $312, %%rdi;"
"add %%r13, %%rdi;"
"call %P1;"
"mov %%rax, %%r12;"
"pop %%rsi;"
"pop %%rdi;"
"mov %%rsi, %%rsp;"
"call z_free;"
"end_%=:"
"add %%r13, %%rsp;"
"pop %%rax;"
"pop %%rbx;"
"pop %%rcx;"
"pop %%rdi;"
"pop %%rsi;"
"pop %%r8;"
"pop %%r9;"
"pop %%r10;"
"pop %%r11;"
"movdqu (%%rsp), %%xmm0;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm1;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm2;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm3;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm4;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm5;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm6;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm7;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm8;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm9;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm10;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm11;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm12;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm13;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm14;"
"add $16, %%rsp;"
"cmp $0, %%r12;"
"je block_%=;"
"jmp *%2;"
"block_%=:"
"ret;"
::
"i"(STACK_SIZE),
"i"(handler),
"m"(origin)
);

r12r13 寄存器是 Golang 中可以随意使用的临时寄存器,我们用 r12 来标识是否要阻断当前调用。而r13 用来参与计算,以确保调用 handler 时栈指针按 16 字节对齐,这是 amd64gcc 的默认约定。
在代码的开头,我们先将 X0 - X14 浮点数寄存器推入栈中,接着推入 Golang 1.17 以上需要使用的整数寄存器。然后调用 z_malloc 申请 40K 的内存替换当前栈,再以原始栈指针为参数调用 handler。在 handler 函数中,根据 Golang 的版本不同,我们可以从栈上存储的寄存器中,或上一函数的 Stack frame 中读出入参。当然也可以根据原始栈指针读取返回地址,从 Golang 符号表中查找函数信息,然后根据 Stack frame 读出上一层的返回地址,循环往复完成栈回溯。
我们甚至可以在 handler 中判断参数是否合法,当参数匹配到我们设置的正则时,可以手动写入 error 返回值到栈上,并返回 false 以将 r12 寄存器置零完成阻断。在 handler 函数执行完成后,从栈上恢复寄存器,并根据 r12 决定返回还是跳转至原函数。
为了更好地进行参数读取和阻断,我使用Templates 编写了一套 Golang 类型反射库,可以在运行时获取 Golang 类型元数据。元数据包含类型的基础类型成员数,每个成员的相对偏移以及占用大小,还有该类型需要占用的浮点/整数寄存器数。在 handler 函数中,我们可以轻松地利用这些元数据分析 Golang 的参数内存布局,正确地取出数据。由于该部分代码细节繁多,限于本文篇幅所以不进行详细讲解,取参与回溯部分请直接阅读仓库代码

回溯停止

对于调用栈的回溯,上面已经解析过了,我们可以取出当前栈顶的返回地址,在符号表中查找地址相关的函数的名称、文件、行号以及栈帧大小。获取栈帧大小后,取出 sp + framesize 的上一层返回地址,循环上述步骤即可。但有一个问题是,我们在哪里结束循环?调用链的层数一定有限,那么第一个函数是哪个?
在 1.2 版本中,Golang 通过判断函数名是否为 runtime.goexitruntime.rt0_go 等入口函数,由此决定是否终止回溯。而对于较新版本的 Golang ,符号信息中增加了一个 funcID 字段,通过 funcID 判断函数类型是否为入口函数。但 funcID 的本质与函数名比较无二,而且 funcID 在版本之间会发生变动,所以最后决定简单地使用函数名判断:

1
2
3
4
5
6
7
8
9
10
constexpr auto STACK_TOP_FUNCTION = {
"runtime.mstart",
"runtime.rt0_go",
"runtime.mcall",
"runtime.morestack",
"runtime.lessstack",
"runtime.asmcgocall",
"runtime.externalthreadhandler",
"runtime.goexit"
};

消息通信

成功获取入参和调用栈后,要如何把消息传输出去?如果需要进行 socket 通信,并且不阻塞 Golang 线程,那就需要驻留一个线程在 Golang 进程内,实现一个简单的生产者消费者模型。那么在无法使用 std::queue 等标准库的情况下,要怎么实现消费丢列,又该如何保证线程安全?
为了尽可能地减少性能影响,我利用 gcc 内置的原子操作实现了一个定长的无锁环形缓冲区,并使用 c-runtime 中实现的 condition variable 做线程同步,实现了一个高效的消息队列。在每个钩子函数触发时,都会将入参和调用栈打包放入队列,如果队列已满则丢弃该消息。
pangolin 注入过程中,我们启动一个消费者线程,从消息队列中消费函数调用信息,序列化为 json 后通过 unix socket 传输到 server

信号屏蔽

Golang 启动时会设置信号处理函数,而在进程收到信号时,内核会随机选择一个线程进行信号处理。我们在 Golang 进程中驻留的几个 cpp 线程有可能被选中用于执行处信号处理函数,但是处理函数默认当前处于 Golang 线程中,读取 fs 寄存器以访问 Golang 的全局 G,但此时 fs 所指向的其实是 glibcTLS,于是导致异常退出。
为了避免这种情况发生,我们需要手动设置驻留的 cpp 线程,令其屏蔽所有信号:

1
2
3
4
5
6
7
8
9
sigset_t mask = {}; 
sigset_t origin_mask = {};

sigfillset(&mask);

if (pthread_sigmask(SIG_SETMASK, &mask, &origin_mask) != 0) {
LOG_ERROR("set signal mask failed");
quit(-1);
}

流程

在学习了原理和实现细节后,我们来解析一下 go-probe 的执行流程,入口函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
#include "go/symbol/build_info.h"
#include "go/symbol/line_table.h"
#include "go/symbol/interface_table.h"
#include "go/api/api.h"
#include <zero/log.h>
#include <csignal>
#include <asm/api_hook.h>
#include <z_syscall.h>

void quit(int status) {
uintptr_t address = 0;
char *env = getenv("QUIT");

if (!env) {
LOG_WARNING("can't found quit env variable");
z_exit_group(-1);
}

if (!zero::strings::toNumber(env, address, 16) || !address) {
LOG_ERROR("invalid quit function address");
z_exit_group(-1);
}

((decltype(quit) *)address)(status);
}

int main() {
INIT_FILE_LOG(zero::INFO, "go-probe");

sigset_t mask = {};
sigset_t origin_mask = {};

sigfillset(&mask);

if (pthread_sigmask(SIG_SETMASK, &mask, &origin_mask) != 0) {
LOG_ERROR("set signal mask failed");
quit(-1);
}

if (!gLineTable->load()) {
LOG_ERROR("line table load failed");
quit(-1);
}

if (gBuildInfo->load()) {
LOG_INFO("go version: %s", gBuildInfo->mVersion.c_str());

CInterfaceTable table = {};

if (!table.load()) {
LOG_ERROR("interface table load failed");
quit(-1);
}

table.findByFuncName("errors.(*errorString).Error", (go::interface_item **)CAPIBase::errorInterface());
}

gSmithProbe->start();

for (const auto &api : GOLANG_API) {
for (unsigned int i = 0; i < gLineTable->mFuncNum; i++) {
CFunc func = {};

if (!gLineTable->getFunc(i, func))
break;

const char *name = func.getName();
void *entry = (void *)func.getEntry();

if ((api.ignoreCase ? strcasecmp(api.name, name) : strcmp(api.name, name)) == 0) {
LOG_INFO("hook %s: %p", name, entry);

if (hookAPI(entry, (void *)api.metadata.entry, api.metadata.origin) < 0) {
LOG_WARNING("hook %s failed", name);
break;
}

break;
}
}
}

pthread_sigmask(SIG_SETMASK, &origin_mask, nullptr);
quit(0);

return 0;
}

需要明确的是,go-probepangolin 注入到 Golang 进程的主线程中临时运行。同时 pangolin 使用 ptrace 持续监听该线程的 syscall 调用,拦截到 main 函数发出的 exitexit_group 调用后,恢复线程状态并结束注入流程。然而可以看到,上面的代码中会优先调用环境变量 QUIT 指向的函数,这又是为何?
在实际的部署过程中,由于资源限制等诸多特殊原因,pangolin 进程可能会在注入期间被 kill。那么此时 main 函数执行的 syscall 就无人拦截,exit_group 会真正地导致业务进程退出。为了让执行 go-probe 的线程能够自我恢复,pangolin 会提前将线程状态快照写入到 Golang 内存中。同时遗留在 Golang 进程中的 shellcode 包含一个 quit 函数,能够根据该快照主动恢复线程,类似于 glibcsetcontext,而 QUIT 环境变量正是 quit 的地址。
在初始化文件日志后,先令当前线程屏蔽所有信号,之后启动的所有子线程都会继承该设置。然后从 ELFsection 中加载符号表、编译信息以及 interface 表,并且为了支持阻断功能,查找 errors.(*errorString) 的地址并保存。执行 gSmithProbe->start() 启动通信客户端后,从符号表中查找 GOLANG_API 所有子项,并进行 Inline Hook。完成以上流程后,恢复信号掩码并调用 quit 函数以通知 pangolin 结束注入。

Node.js进程注入

前言

上篇文章实现了对Python进程的代码注入,今天接着来尝试Node.js的注入。

Inspector

根据Node.js的官方文档得知,在Node.js 8之后加入了新的调试机制(Inspector API),在启动时加入”–inspect”参数或者向进程发送SIGUSR1信号,就会激活检查器。

1
2
$ node
$ kill -USR1 $(pidof node)

激活检查器后,终端会打印信息:

Debugger listening on ws://127.0.0.1:9229/f1f12ee0-4f35-4e61-836c-71d175a607e3

检查器监听本地端口9929,使用websocket协议,协议约定可见文档,连接检查器可以使用node自带的inspect client:

1
$ node inspect -p $(pidof node)

连接后可以通过”exec(‘console.log(123)’)”在目标进程中执行node代码,或者执行”repl”开启交互模式。但是自带的inspect client功能简单,如果需要更好的体验,可以使用chrome的调试模块,在chrome中访问”chrome://inspect”即可。

协议

node-inspect就是检查器客户端的官方代码仓库,通过阅读inspect_client.js发现,交互协议就是简单的json字符串,每个请求和响应通过id字段一一对应。
例如请求开启调试时,通过websocket协议发送字符串:

1
2
3
4
{
"id": 1,
"method": "Debugger.enable"
}

检查器返回响应:

1
2
3
4
5
6
{
"id": 1,
"result": {
"debuggerId": "(D750AD89818A150E3155AC1D6ECB7BB)"
}
}

如果你想在目标进程中执行代码,类似于”exec(‘console.log(123)’)”命令,你只需要使用Runtime.evaluate,发送请求:

1
2
3
4
5
6
7
8
{
"id": 2,
"method": "Runtime.evaluate",
"params": {
"expression": "console.log(123)",
"includeCommandLineAPI": true
}
}

参数需要设置”includeCommandLineAPI”,否则注入的代码无法使用require函数。

Python进程注入

前言

上篇文章讨论了Linux进程注入,能够在机器码层面进行代码注入,但如果现在有一个Python进程在运行,如何才让将Python代码注入进去呢?

inject by gdb

目前有两个项目能够对Python虚拟机进行注入,分别是pylane以及pyrasite。简单浏览两个项目,可以发现都是使用GDB在进程中进行函数调用

1
2
3
4
5
6
7
8
9
10
11
12
13
class Injector(object):
def generate_gdb_codes(self):
# ...
return [
# use char in case of symbol PyGilState_STATE not found
'call $gil_state = (char) PyGILState_Ensure()',
'call (void) PyRun_SimpleString("%s")' % prepare_code,
'call (void) PyRun_SimpleString("%s")' % run_code,
'call (void) PyRun_SimpleString("%s")' % cleanup_code,
# make sure previous codes are safe.
# gdb exit without GIL release is a disaster for target process.
'call (void) PyGILState_Release($gil_state)',
]

GDB附加到进程后,使用call命令在目标进程中依次调用函数PyGILState_Ensure、PyRun_SimpleString、PyGILState_Release就能在Python虚拟机中运行代码。

1
2
3
4
5
6
7
sudo gdb \
-p $pid \
-ex 'call (int)PyGILState_Ensure()' \
-ex 'call (int)PyRun_SimpleString("print(123)")' \
-ex 'call (int)PyGILState_Release($1)' \
-ex 'set confirm off' \
-ex quit

使用上诉命令,可以直接将”print(123)”注入到Python进程中。

inject by pangolin

既然只需要在进程中调用三个函数,就可以完成Python代码注入,那么我们是否可以使用pangolin将某个程序注入到进程中,完成这三个函数的调用。这也就是项目python-inject的由来,它使用pangolin进行Python代码注入,不依赖于GDB。

符号查找

首先我们需要查找函数在目标进程中的地址,解析Python的ELF符号表,然后加上进程基址,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
#include <common/log.h>
#include <common/cmdline.h>
#include <common/utils/process.h>
#include <elfio/elfio.hpp>

typedef int (*PFN_RUN)(const char *command);
typedef int (*PFN_ENSURE)();
typedef void (*PFN_RELEASE)(int);

constexpr auto PYTHON = "bin/python";
constexpr auto PYTHON_LIBRARY = "libpython";
constexpr auto PYTHON_CALLER = "python_caller";

int main(int argc, char ** argv) {
cmdline::parser parse;

parse.add<int>("pid", 'p', "pid", true, 0);
parse.add<std::string>("source", 's', "python source file", true, "");
parse.add<std::string>("pangolin", '\0', "pangolin path", true, "");
parse.add("file", '\0', "pass source by file");

parse.parse_check(argc, argv);

int pid = parse.get<int>("pid");

CProcessMap processMap;

if (
!CProcess::getFileMemoryBase(pid, PYTHON_LIBRARY, processMap) &&
!CProcess::getFileMemoryBase(pid, PYTHON, processMap)
) {
LOG_ERROR("find target failed");
return -1;
}

LOG_INFO("find target: 0x%lx -> %s", processMap.start, processMap.file.c_str());

ELFIO::elfio reader;

if (!reader.load(processMap.file)) {
LOG_ERROR("open elf failed: %s", processMap.file.c_str());
return -1;
}

auto it = std::find_if(
reader.sections.begin(),
reader.sections.end(),
[](const auto& s) {
return s->get_type() == SHT_DYNSYM;
});

if (it == reader.sections.end()) {
LOG_ERROR("can't find symbol section");
return -1;
}

unsigned long baseAddress = 0;

if (reader.get_type() != ET_EXEC) {
auto sit = std::find_if(
reader.segments.begin(),
reader.segments.end(),
[](const auto& s) {
return s->get_type() == PT_LOAD;
});

if (sit == reader.segments.end()) {
LOG_ERROR("can't find load segment");
return -1;
}

baseAddress = processMap.start - (*sit)->get_virtual_address();
}

PFN_ENSURE pfnEnsure = nullptr;
PFN_RUN pfnRun = nullptr;
PFN_RELEASE pfnRelease = nullptr;

ELFIO::symbol_section_accessor symbols(reader, *it);

for (ELFIO::Elf_Xword i = 0; i < symbols.get_symbols_num(); i++) {
std::string name;
ELFIO::Elf64_Addr value = 0;
ELFIO::Elf_Xword size = 0;
unsigned char bind = 0;
unsigned char type = 0;
ELFIO::Elf_Half section = 0;
unsigned char other = 0;

if (!symbols.get_symbol(i, name, value, size, bind, type, section, other)) {
LOG_ERROR("get symbol %lu failed", i);
return -1;
}

if (name == "PyGILState_Ensure")
pfnEnsure = (PFN_ENSURE)(baseAddress + value);
else if (name == "PyRun_SimpleString")
pfnRun = (PFN_RUN)(baseAddress + value);
else if (name == "PyGILState_Release")
pfnRelease = (PFN_RELEASE)(baseAddress + value);
}

if (!pfnEnsure || !pfnRun || !pfnRelease) {
LOG_ERROR("can't find python symbols");
return -1;
}

LOG_INFO("ensure func: %p run func: %p release func: %p", pfnEnsure, pfnRun, pfnRelease);

std::string source = parse.get<std::string>("source");
std::string pangolin = parse.get<std::string>("pangolin");
std::string caller = CPath::join(CPath::getAPPDir(), PYTHON_CALLER);

char callerCommand[1024] = {};

snprintf(
callerCommand, sizeof(callerCommand),
"%s %s %d %p %p %p",
caller.c_str(), source.c_str(), parse.exist("file"),
pfnEnsure, pfnRun, pfnRelease
);

int err = execl(
pangolin.c_str(), pangolin.c_str(),
"-c", callerCommand,
"-p", std::to_string(pid).c_str(),
nullptr
);

if (err < 0) {
LOG_ERROR("exec pangolin failed: %s", strerror(errno));
return -1;
}

return 0;
}

函数调用

我们现在知道了函数在进程中的地址,只需要注入程序并传递地址参数,在进程中完成三个函数的调用,就可以实现Python代码注入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <crt_asm.h>
#include <crt_log.h>
#include <crt_utils.h>

typedef int (*PFN_RUN)(const char *command);
typedef int (*PFN_ENSURE)();
typedef void (*PFN_RELEASE)(int);

int main(int argc, char ** argv, char ** env) {
if (argc < 6) {
LOG("usage: ./program source [0|1](source is file) address address address");
return -1;
}

char *source = argv[1];
int is_file = !strcmp(argv[2], "1");

PFN_ENSURE pfn_ensure = (PFN_ENSURE)strtoul(argv[3], NULL, 16);
PFN_RUN pfn_run = (PFN_RUN)strtoul(argv[4], NULL, 16);
PFN_RELEASE pfn_release = (PFN_RELEASE)strtoul(argv[5], NULL, 16);

if (!pfn_ensure || !pfn_run || !pfn_release) {
LOG("func address error");
return -1;
}

LOG("inject python source: %s flag: %d", source, is_file);

char *code = source;

if (is_file) {
char *buffer = NULL;
long length = read_file(source, &buffer);

if (length <= 0) {
LOG("read file failed: %s", source);
return -1;
}

code = malloc(length + 1);

if (!code) {
LOG("malloc code memory failed");
free(buffer);
return -1;
}

memset(code, 0, length + 1);
memcpy(code, buffer, length);

free(buffer);
}

int state = pfn_ensure();
int err = pfn_run(code);
pfn_release(state);

if (is_file) {
free(code);
}

return err;
}

void _main(unsigned long * sp) {
int argc = *sp;
char **argv = (char **)(sp + 1);
char **env = argv + argc + 1;

__exit(main(argc, argv, env));
}

void _start() {
CALL_SP(_main);
}

fs寄存器

也许你有疑问,为什么要将函数计算和调用分开,为什么不直接在目标进程中计算函数地址?实际上,我在项目刚开始的时候也是这样设计的,但是在测试过程中一直crash,经过调试发现这和fs寄存器相关。

glibc

如果你使用C/C++编写了一个依赖于标准库的程序,那么程序加载时会先由glibc进行初始化,而在glibc中,每个线程初始化时都会修改fs寄存器指向一段私有的内存,用来存储线程局部变量等数据。
Python也会设置私有的fs寄存器,如果将一个依赖glibc的程序注入进去,那么程序运行时会修改fs寄存器。而此时调用Python的函数,就会导致crash,因为函数内部会访问fs寄存器,但此时的fs寄存器已经被修改了。
为了避免fs寄存器被修改,只能编写一个不依赖于标准库的程序(-nostdlib),类似于我上面的代码,从程序入口点”_start”开始编写。不使用标准库进行符号解析以及地址计算,开发难度较大,所以权衡之下我选择将两者分离。

arch_prctl

使用arch_prctl系统调用可以修改fs寄存器,所以我尝试过一种解决方法,在函数调用之前临时将fs寄存器修改为原有值。所以我修改了pangolin,在进行注入时先获取当前fs寄存器值存入环境变量,在程序注入后根据环境变量恢复寄存器。
但是结果还是会导致crash,因为glibc的架构复杂,所以我没有继续深究,总之之后涉及fs寄存器的一切我都会敬而远之。

Linux进程注入

前言

将代码注入到特定进程空间内执行,是一种十分geek的行为,可用于破解、安全防护等。不像Windows有CreateRemoteThread可以直接在目标进程内创建线程,在Linux下进行进程注入会显得复杂些。
目前比较常用的手段,就是计算dlopen函数在libc.so中的位置,然后利用ptrace连接目标进程,更改PC地址使其运行该函数,但是该方式只能针对非静态编译的程序。

mandibule

我在github上发现了项目mandibule,该项目不使用任何C标准库(-nostdlib),类似于strcmp之类的函数都用C重写一遍,所以编译出来的产物就是一个无依赖shellcode。

虚拟地址空间

作者巧妙地使用编译器默认不进行函数排序的特点,在唯一的源文件mandibule.c开头编写函数:

1
2
3
4
5
6
unsigned long mandibule_beg(int aligned)
{
if(!aligned)
return (unsigned long)mandibule_beg;
return (unsigned long)mandibule_beg - ((unsigned long)mandibule_beg % 0x1000);
}

因为项目只有这一个源文件,所以编译出来只有一个object文件,也就是说该函数会在程序代码段的最开头。在mandibule.c的末尾编写函数:

1
2
3
4
5
6
unsigned long mandibule_end(void)
{
uint8_t * p = (uint8_t*)"-= end_rodata =-";
p += 0x1000 - ((unsigned long)p % 0x1000);
return (unsigned long)p;
}

编译后该函数当然会在代码段的末尾,作者编写这两个函数的目的是为了在该程序运行时,可以通过调用mandibule_beg(1)、mandibule_end获得该程序的虚拟地址范围。
一般来说ELF加载到内存中时,ELF头部之后就是TEXT代码段,又因为这些段是按页对齐的,所以调用mandibule_beg(1)返回自身函数地址向下对齐页,大概率获得ELF头部的虚拟地址,也就是程序虚拟空间的开始。
但是获取程序虚拟空间末尾地址却没这么简单,因为在TEXT段后面还有DATA、RODATA等数据段。mandibule_end内使用了一个用不到字符串,该字符串肯定会在RODATA中,所以使用该字符串地址向上对齐页能够获得末尾的虚拟地址。
根据这两个地址,我们可以确定程序运行所需的代码、数据地址空间,你甚至可以将该范围的数据全部拷贝到一个堆空间,更改页属性为可执行,然后直接跳转到入口点运行起来,当然,这需要你编译的代码是位置无关的(-PIC)。

注入流程

mandibule进行代码注入的步骤:

  • 使用ptrace附加到目标进程
  • 备份进程寄存器、内存数据
  • 将自身运行时地址空间内的所有数据拷贝到目标进程内
  • 修改目标进程的寄存器,其实跳转到入口地址
  • 等待目标进程调用exit系统调用
  • 恢复寄存器、内存数据
  • 恢复进程的运行

第三步用到的地址范围就是mandibule_beg、mandibule_end所获得的,也相当于将程序自身拷贝到目标进程中运行。程序在目标进程中运行时,会使用一个自己编写ELF loader加载需要注入的代码。主要是使用mmap将程序段映射到进程空间,如果依赖动态库则还需要映射interpreter,最后生成一个假栈并设置好argv、env等信息,最后直接跳转到程序入口地址。

缺陷

使用mandibule可以对静态编译的程序进行注入,但是项目的设计有些缺陷:

  • 没有将shellcode与ptrace注入剥离,导致耦合严重
  • 项目不使用C标准库,进行二次开发较为麻烦

另外我在使用中发现了许多的bug,可见项目issue,我在推特上联系了作者,但他并没有要接着进行维护的意思。

pangolin

最后我决定对项目进行重构,将ELF loader编写成独立的shellcode模块,而ptrace注入部分完全可以用熟悉的C++开发。具体的代码细节不赘述,可以自行在github上查看项目

链接脚本

在我的设计中,将shellcode部分编译成独立的so文件,然后根据导出符号的地址获取所有shellcode数据,将其写入目标进程中运行。但是在实现中遇到了问题,因为代码编译后不仅仅只有代码段,还包括RODATA等数据段,我们需要将所有数据都包括进来,而导出符号只会出现在TEXT中。
我知道在链接阶段可以指定所有符号的地址,让编译器根据我们的要求进行符号分段、排序。最后通过搜索学习,我编写了链接脚本:

1
2
3
4
5
6
7
8
9
10
SECTIONS
{
.text :
{
*(.begin);
*(.text.*);
*(.rodata.*);
*(.end);
}
}

使用该脚本进行链接,最后生成的程序只会有一个TEXT段,字符串等数据都会在该段中。另外我编写了三个导出函数:

1
2
3
void __attribute__ ((section (".begin"))) shellcode_begin();
void shellcode_start();
void __attribute__ ((section (".end"))) shellcode_end();

可以看到我指定了两个section分配给shellcode_begin、shellcode_end,也对应了链接脚本中的begin、end。所以最后生成的程序中,符号shellcode_begin会在TEXT段的最开始,shellcode_end在TEXT的末尾。
我们只需要将shellcode_begin至shellcode_end之间的数据拷贝到目标进程空间,然后跳转到shellcode_start - shellcode_begin的入口偏移处,就可以在进程中运行我们编写的shellcode。

多线程

mandibule使用ptrace附加到进程主线程,此时别的线程依旧在运行中,在将数据拷贝到目标进程中时,覆盖的范围一般从ELF头部开始。如果覆盖的范围较大,就可能覆盖到代码段,而恰好此时其余的线程调用了一个被覆盖了的函数,就会导致程序崩溃。
为了减少多线程进程crash的可能性,我对内存覆盖进行了优化,先覆盖一小段shellcode,在目标进程中调用mmap系统调用申请内存,用申请的内存来储存较大的ELF loader。并且在ptrace阶段,会附加到所有线程,使其临时暂停运行。

栈空间

shellcode在目标进程中运行时,使用的是线程暂停时的栈,对于普通的程序,栈空间足够,所以不需要担心出现意外情况。但是对于golang这类自己管理栈的程序,栈空间十分小,shellcode在进程中运行时可能会溢出栈空间,覆盖掉程序数据最终导致crash。
另外,如果想在目标进程中驻留,可以在注入后创建新线程,但是在程序恢复运行后,新线程调用getenv等API运行会导致crash。因为argv、env等数据存在于我们注入时构建的栈上,但是该栈是别的线程拥有的,可能数据早已丢失。
为了解决这两个问题,我在ELF loader的开头使用mmap申请一块足够大的栈空间,并且修改SP寄存器更换栈空间。

1
2
3
4
5
6
7
8
void loader_main(void *ptr) {
LOG("elf loader start");

char *stack = malloc(STACK_SIZE);
char *stack_top = stack + STACK_SIZE;

FIX_SP_JMP(stack_top, elf_loader, ptr);
}

还有许多的小细节,都存在于代码中,如果感兴趣可以自行阅读。

flutter逆向工程一

前言

几个星期前, 出于某些原因我想逆向某个APK文件, 和平常一样, 我使用apktool/dex2jar/jd-gui三件套开始工作. 出乎意料的是, 我没有在classes.dex里面找到任何程序相关的逻辑, 取而代之的是一些”flutter”的代码段.

初探

flutter是谷歌开发的跨平台框架, 初略的探索之后, 我得知程序的逻辑都存在于”libapp.so”文件中.

1
2
3
4
5
6
7
8
9
10
11
12
13
➜ tree lib
lib
├── arm64-v8a
│   ├── libapp.so
│   └── libflutter.so
├── armeabi-v7a
│   ├── libapp.so
│   └── libflutter.so
└── x86_64
├── libapp.so
└── libflutter.so

3 directories, 6 files

APK解开后, lib目录下面会有两个库文件, “libflutter.so”是框架的运行时支持, “libapp.so”则是逻辑层编译而成的产物.

1
2
3
4
5
6
7
8
9
10
➜ readelf --symbols --wide libapp.so

Symbol table '.dynsym' contains 6 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 00000000 0 NOTYPE LOCAL DEFAULT UND
1: 00001000 12 FUNC GLOBAL DEFAULT 1 _kDartBSSData
2: 00002000 9616 FUNC GLOBAL DEFAULT 2 _kDartVmSnapshotInstructions
3: 00005000 24400 FUNC GLOBAL DEFAULT 3 _kDartVmSnapshotData
4: 0000b000 0x1edf30 FUNC GLOBAL DEFAULT 4 _kDartIsolateSnapshotInstructions
5: 001f9000 0x175bb0 FUNC GLOBAL DEFAULT 5 _kDartIsolateSnapshotData

查看导出符号, 只有上面四个, 但从名称上分析并不像函数. 接下来开始使用IDA分析:

符号”_kDartVmSnapshotData”看起来的确不像是导出函数, 更像是序列化之后的数据, 应该是提供给dart虚拟机解释执行的.

探索

我搜索了很久, 几乎没有关于flutter逆向相关的文章, 只有reverse-engineering-flutter-apps-part-1这篇文章进行了剖析.

快照

“libapp.so”实际上是dart编译生成的快照文件, 这种快照格式称为”AOT”, 上面提到的四个导出符号就是快照数据的起始偏移:

  • “_kDartVmSnapshotData”: 虚拟机运行所需的Object等相关数据
  • “_kDartVmSnapshotInstructions”: 虚拟机运行所需指令相关数据
  • “_kDartIsolateSnapshotData”: 逻辑层运行所需的Object等相关数据
  • “_kDartIsolateSnapshotInstructions”: 逻辑层运行所需指令相关数据

官方没有文档公布快照AOT快照的数据格式, 不过幸好flutter和dart都是开源的, 我们可以调试AOT生成/加载的全过程, 就能知道快照里存了什么数据, 在逆向过程中又能如何利用这些数据.

编译

文章中提到了如何单纯的将dart文件转变成”libapp.so”:

1
2
~/flutter/bin/cache/dart-sdk/bin/dart ~/flutter/bin/cache/artifacts/engine/linux-x64/frontend_server.dart.snapshot --sdk-root ~/flutter/bin/cache/artifacts/engine/common/flutter_patched_sdk_product/ --strong --target=flutter --aot --tfa -Ddart.vm.product=true --output-dill app.dill main.dart
gen_snapshot --causal_async_stacks --deterministic --snapshot_kind=app-aot-elf --elf=libapp.so --strip app.dill

上面使用的是flutter sdk中包含的dart相关工具, 我测试之后发现只需要dart sdk就可以完成AOT的生成. 那么接下来, 我们需要编译一个带有调试信息的dart sdk:

1
2
3
4
5
6
7
8
9
10
11
# dev tool
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$PATH:$PWD/depot_tools"

# fetch source
mkdir dart-sdk; cd dart-sdk
fetch dart

# build
cd sdk
./tools/build.py -m debug --no-goma -a x64 --debug-opt-level 0 all

查看编译生成的产物:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
➜ tree -L 1
.
├── -
├── app.dill
├── args.gn
├── build.ninja
├── build.ninja.d
├── compressed_observatory_archive.tar
├── dart
├── dart2js_nnbd_strong_outline.dill
├── dart2js_nnbd_strong_platform.dill
├── dart2js_nnbd_strong_platform.dill.d
├── dart2js_outline.dill
├── dart2js_platform.dill
├── dart2js_platform.dill.d
├── dart2js_server_nnbd_strong_outline.dill
├── dart2js_server_nnbd_strong_platform.dill
├── dart2js_server_nnbd_strong_platform.dill.d
├── dart2js_server_outline.dill
├── dart2js_server_platform.dill
├── dart2js_server_platform.dill.d
├── dartdevc.dill
├── dartdevc.js
├── dartdevc.js.map
├── dartdev.dill
├── dart_precompiled_runtime
├── dart_precompiled_runtime_product
├── dart-sdk
├── ddc_outline.dill
├── ddc_outline_sound.dill
├── ddc_platform.dill
├── ddc_platform.dill.d
├── ddc_platform_sound.dill
├── ddc_platform_sound.dill.d
├── dds.dart.snapshot
├── dev_compiler
├── exe.stripped
├── gen
├── gen_kernel_bytecode.dill
├── gen_snapshot
├── gen_snapshot_fuchsia
├── gen_snapshot_host_targeting_host
├── gen_snapshot_product
├── gen_snapshot_product_fuchsia
├── gen_snapshot_product_host_targeting_host
├── icudtl.dat
├── icudtl_extra.dat
├── kernel-service.dart.snapshot
├── libapp.so
├── libentrypoints_verification_test_extension.so
├── libentrypoints_verification_test_extension.so.TOC
├── libffi_test_dynamic_library.so
├── libffi_test_dynamic_library.so.TOC
├── libffi_test_functions.so
├── libffi_test_functions.so.TOC
├── libsample_extension.so
├── libsample_extension.so.TOC
├── libtest_extension.so
├── libtest_extension.so.TOC
├── obj
├── observatory_archive.tar
├── offsets_extractor
├── offsets_extractor_precompiled_runtime
├── out.js
├── out.js.deps
├── out.js.map
├── process_test
├── run_vm_tests
├── toolchain.ninja
├── vm_outline_strong.dill
├── vm_outline_strong_product.dill
├── vm_outline_strong_stripped.dill
├── vm_platform_strong.dill
├── vm_platform_strong.dill.d
├── vm_platform_strong.dill.S
├── vm_platform_strong_product.dill
├── vm_platform_strong_product.dill.d
├── vm_platform_strong_stripped.dill
├── vm_platform_strong_stripped.dill.d
└── zlib_bench

5 directories, 73 files

dart-sdk子目录就和我们在官网下载到的版本一样, 但是它里面的程序符号表已被去除, 我们无法基于源码调试, 我们只能使用顶层那些散乱的产物. 根据官网的描述, 使用dart2native就能生成AOT快照:

1
dart2native bin/main.dart -k aot

在我们的目标产物中, 查看dart2native内容, 发现它只是个脚本文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/usr/bin/env bash
# Copyright (c) 2019, the Dart project authors. Please see the AUTHORS file
# for details. All rights reserved. Use of this source code is governed by a
# BSD-style license that can be found in the LICENSE file.

# Run dart2native.dart.snapshot on the Dart VM

function follow_links() {
file="$1"
while [ -h "$file" ]; do
# On Mac OS, readlink -f doesn't work.
file="$(readlink "$file")"
done
echo "$file"
}

# Unlike $0, $BASH_SOURCE points to the absolute path of this file.
PROG_NAME="$(follow_links "$BASH_SOURCE")"

# Handle the case where dart-sdk/bin has been symlinked to.
BIN_DIR="$(cd "${PROG_NAME%/*}" ; pwd -P)"
SNAPSHOTS_DIR="${BIN_DIR}/snapshots"
DART="$BIN_DIR/dart"

exec "$DART" "${SNAPSHOTS_DIR}/dart2native.dart.snapshot" $*

脚本使用dart执行dart2native.dart.snapshot, 说明dart2native.dart.snapshot是由dart编写而成的, 具体的源码可以查看github仓库. 通过阅读源码, 发现它其实是通过调用gen_snapshot进行的快照生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
final String kernelFile = path.join(tempDir.path, 'kernel.dill');
final kernelResult = await generateAotKernel(Platform.executable, genKernel,
productPlatformDill, sourcePath, kernelFile, packages, defines,
enableExperiment: enableExperiment);
if (kernelResult.exitCode != 0) {
stderr.writeln(kernelResult.stdout);
stderr.writeln(kernelResult.stderr);
await stderr.flush();
throw 'Generating AOT kernel dill failed!';
}

if (verbose) {
print('Generating AOT snapshot.');
}

final String snapshotFile = (outputKind == Kind.aot
? outputPath
: path.join(tempDir.path, 'snapshot.aot'));
final snapshotResult = await generateAotSnapshot(genSnapshot, kernelFile,
snapshotFile, debugPath, enableAsserts, extraOptions);
if (snapshotResult.exitCode != 0) {
stderr.writeln(snapshotResult.stdout);
stderr.writeln(snapshotResult.stderr);
await stderr.flush();
throw 'Generating AOT snapshot failed!';
}

先使用dart生成kernel.dill, 然后使用gen_snapshot将kernel.dill转成AOT, 这也与文章中提到的方法一致.

生成

进入dart-sdk子目录, 编辑hello.dart:

1
2
3
void main() {
print('Hello, World!');
}

接着生成kernel.dill:

1
./bin/dart bin/snapshots/gen_kernel.dart.snapshot --platform lib/_internal/vm_platform_strong_product.dill --aot -Ddart.vm.product=true -o kernel.dill hello.dart

使用gen_snapshot生成AOT快照:

1
./bin/utils/gen_snapshot --snapshot_kind=app-aot-elf --elf=libapp.so kernel.dill

经历重重关卡, 我们成功将dart源码转成了AOT快照, 那么接下来, 我们只需要使用gdb一步步跟踪快照的生成.

软路由折腾记录三

ADGuard

在路由器层面进行广告过滤, 并没有特别有效的方法, 到目前为止最有效的依旧是浏览器插件. 因为广告过滤的核心是拦截js/图片, 路由器上的透明代理很难窥视HTTPS传输的内容, 所以目前的路由器插件仅能对HTTP进行过滤. ADGuard Home另辟蹊径, 它提供DNS服务, 对黑名单里的广告域名进行拦截, 返回一个无法访问的IP.

部署

由于clash提供了DNS服务, 所以需要调整ADGuard/clash的上下游顺序.

1
dnsmasq -> ADGuard -> clash

首先启动ADGuard:

1
docker run --name adguardhome -v /my/own/workdir:/opt/adguardhome/work -v /my/own/confdir:/opt/adguardhome/conf -p 5301:53/tcp -p 5301:53/udp -p 3000:3000/tcp -d adguard/adguardhome

ADGuard的DNS服务监听5031端口, 而clash监听5300端口. 首先编辑dnsmasq配置文件, 设置上游服务器为ADGuard:

1
server=127.0.0.1#5301

接着访问软路由3000端口, 这是ADGuard提供的web界面, 在里面配置上游DNS为5300. 接下来就可以自有配置过滤规则, 配置完后可以执行命令验证广告域名是否被屏蔽:

1
dig ad.xxx.xxx

效果

由于广告推送技术的发展, 现在光屏蔽域名还远远不够. 实测效果来看, 国内各大视频网站的广告均无法过滤, 而类似于谷歌联盟的图片广告可以屏蔽. 另外ADGuard不仅仅用于广告过滤, 还能屏蔽一些统计js, 针对斗鱼/虎牙等还能屏蔽p2p通信, 这些都需要在web界面里勾选一些开源规则.
但需要注意, 屏蔽某些域名可能导致网站体验不佳, 例如禁用p2p后斗鱼/虎牙会偶尔卡顿.

ipset

在使用了tproxy透明代理UDP流量后, 所有TCP/UDP流量均由clash处理, 包括与大陆ip的通信. 由于UDP转发本身比较耗费CPU, 所以我考虑能不能让大陆ip直接在iptables层放行, 只有国外ip的流量会通过clash.
ipset是iptables提供的工具, 你可以给一组ip取一个名称, 然后在iptables规则中检测目标ip是否属于该组. 所以我们只需要把大陆的ip网段添加到一个组中, 取名为”china”, 接着新建规则, 如果ip属于”china”则不进行透明代理.

1
2
3
4
5
6
wget http://www.ipdeny.com/ipblocks/data/countries/cn.zone

ipset -N china hash:net
for i in $(cat ./cn.zone ); do
ipset -A china $i;
done

接着在iptables相应的表中添加规则:

1
2
iptables -t mangle -A CLASH -p udp -m set --match-set china dst -j RETURN
iptables -t nat -A CLASH -p tcp -m set --match-set china dst -j RETURN

配置完成后, 只有国外ip的访问会经过clash, 而大陆ip的访问由iptables直接转发.

软路由折腾记录二

前言

我从大二开始接触linux, 最开始安装的就是基于debian的kali, 之后直接将debian作为主系统日常使用. 包括现在购买的VPS, 又或是wsl都安装的debian, 就如同我对firefox一样, 产生了深厚的感情.

改造

接上篇文章, 历经千幸万苦终于可以在OpenWrt上运行Docker后, tproxy透明代理失效了. 因为OpenWrt系统十分精简, 再加上是我自己编译的版本, 缺少官方的软件源, 所以调试变得异常困难. 最终我不得不放弃, 转向debian. 我熟练地下载官方镜像, 使用rufus写入U盘, 按部就班的完成了系统安装.
那么接下来就需要将它改造成路由器.

网桥

我的主机有4个网口, 我将第一个网口当做WAN, 连接着联通光猫. 那么我需要将剩下三个网口进行桥接, 使它们处于一个网段, 当为LAN口使用.

1
2
3
4
5
6
# 安装
apt install bridge-utils

# 添加桥接网卡
brctl addbr br0
brctl addif br0 enp0s8 enp0s9

你可以选择使用brctl手动添加桥接网卡, 但是重启之后会消失, 所以建议写入网络配置:

1
2
3
4
5
6
7
# /etc/network/interfaces
auto br0
iface br0 inet static
address 192.168.12.1
broadcast 192.168.12.255
netmask 255.255.255.0
bridge-ports enp0s8 enp0s9

bridge-ports参数替换成具体的网卡, 上面的例子只桥接了两个网卡, 这并不是我实际使用的配置, 仅供参考.

DNS/DHCP

路由器最重要的自然是DHCP/DNS, 而dnsmasq帮我们轻松搞定:

1
apt install dnsmasq

dnsmasq会提供DHCP/DNS服务, DNS默认转发到上游DNS, 暂时不需要修改, 但是DHCP需要修改配置文件指定网段等信息:

1
2
3
# /etc/dnsmasq.conf
interface=br0
dhcp-range=192.168.12.100,192.168.12.250,72h

转发

现在将电脑网线插入J1900主机网口, 电脑可以使用DHCP获取到IP, 可惜现在还上不了网. 因为linux默认将目标IP不是本机的流量全部DROP, 首先打开IP转发:

1
2
# 重启后失效
echo 1 > /proc/sys/net/ipv4/ip_forward

光开启转发还是无法上网, 因为内网网段访问外网需要NAT, 所以需要配置iptables:

1
iptables -t nat -A POSTROUTING -s 192.168.12.0/24 -o enp0s3 -j MASQUERADE

enp0s3是WAN口网卡, MASQUERADE将把子网流量自动NAT转换, 现在你可以自由的进行网上冲浪了.

复现

完成上面一切, debian现在与OpenWrt有着同样的功能, 而且还废弃了那些累赘的组件. 我现在准备安装docker-ce, 过程很迅速, 安装完后我开始基于docker-ce搭建自己的SMB服务.
一切都完成了, 我长舒一口气, 我在终端执行

1
dig google.com @8.8.8.8

命令卡住了!!! tproxy又罢工了!!! 我突然意识到我错怪了OpenWrt, 这似乎是Docker惹的祸, 于是我又开始了漫长的debug.

调试

网络架构


这张图显示的是linux的网络架构, 包括iptables的链条顺序、路由选择, 下面所有的调试都是基于这张图.

Docker

我知道Docker的网络建立在iptables上, 它一样使用虚拟桥接网卡连接各个容器, 然后在iptables添加规则进行NAT. 可是没理由它会影响到主机的网络, 我一条一条地查看Docker添加的防火墙规则, 唯一异常的点是它将filter表的FORWARD默认策略改成了DROP. 但tproxy压根不会经过FORWARD, 路由规则会让流量进入本地协议栈.
根据架构图可以知道, 如果包进入了filter表的INPUT链, 那么说明流量已经过了路由选择, 即将进入本地协议栈. 如果tproxy正常工作, 修改了包的结构体, 那么此时代理端口与包是相关联的, 所以包会被正常接收.
我开始猜测tproxy没有正常运作, 导致路由失败, 包被FORWARD出去了:

1
iptables -t filter -I INPUT 1 -p udp -j LOG --log-prefix '** SUSPECT filter INPUT**'

我添加了一条iptables规则, 当匹配到包时会打印出日志, 于是我重复了一次DNS查询操作. 日志出现了, 所以我断定它已经走过了路由选择. 我又接着猜测了几种可能:

  • tproxy关联包信息失败
  • IP_TRANSPARENT属性失效

这两种情况我都没有有效的测试手段, 所以我陷入了困境, 不过我觉得原因大概率是docker-ce加载了某个内核模块. 于是我使用dpkg查看所有安装的包, 只发现了一个aufs.ko, 貌似不是很符合.
当我束手无策时, 我发现了这篇帖子TPROXY compatibility with Docker.
作者遇到了和我一样的问题, 于是它使用strace跟踪dockerd的系统调用, 发现它加载了一个内核模块”br_netfilter”. 我不抱任何希望的执行了”rmmod br_netfilter”, 它恢复了!!! 原来是这个br_netfilter的问题!!!

ebtables

这时我才仔细去观察上面的架构图, 我发现我遗漏了一个问题, 就是网桥的流量会走Link Layer, 所以可能是这部分出了问题. 图中的蓝色表代表的是ebtables, 绿色表代表iptables, ebtables可以理解成链路的iptables.
我想弄清楚流量的具体走向, 所以我在每个点都加上log规则, 数据包的流向将一览无遗:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# raw
iptables -t raw -I PREROUTING 1 -p udp -j LOG --log-prefix '** SUSPECT raw PREROUTING**'
iptables -t raw -I OUTPUT 1 -p udp -j LOG --log-prefix '** SUSPECT raw OUTPUT**'

# mangle
iptables -t mangle -I PREROUTING 1 -p udp -j LOG --log-prefix '** SUSPECT mangle PREROUTING**'
iptables -t mangle -I INPUT 1 -p udp -j LOG --log-prefix '** SUSPECT mangle INPUT**'
iptables -t mangle -I FORWARD 1 -p udp -j LOG --log-prefix '** SUSPECT mangle FORWARD**'

# nat
iptables -t nat -I PREROUTING 1 -p udp -j LOG --log-prefix '** SUSPECT nat PREROUTING**'
iptables -t nat -I INPUT 1 -p udp -j LOG --log-prefix '** SUSPECT nat INPUT**'

# filter
iptables -t filter -I INPUT 1 -p udp -j LOG --log-prefix '** SUSPECT filter INPUT**'
iptables -t filter -I FORWARD 1 -p udp -j LOG --log-prefix '** SUSPECT filter FORWARD**'

# BROUTING
ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp --log-level 6 --log-ip --log-prefix "TRACE: eb:broute:BROUTING" -j CONTINUE

# nat
ebtables-legacy -t nat -A OUTPUT -p ipv4 --ip-proto udp --log-level 6 --log-ip --log-prefix "TRACE: eb:nat:OUTPUT" -j CONTINUE
ebtables-legacy -t nat -A PREROUTING -p ipv4 --ip-proto udp --log-level 6 --log-ip --log-prefix "TRACE: eb:nat:PREROUTING" -j CONTINUE
ebtables-legacy -t nat -A POSTROUTING -p ipv4 --ip-proto udp --log-level 6 --log-ip --log-prefix "TRACE: eb:nat:POSTROUTING" -j CONTINUE

# filter
ebtables-legacy -t filter -A INPUT -p ipv4 --ip-proto udp --log-level 6 --log-ip --log-prefix "TRACE: eb:filter:INPUT" -j CONTINUE
ebtables-legacy -t filter -A FORWARD -p ipv4 --ip-proto udp --log-level 6 --log-ip --log-prefix "TRACE: eb:filter:FORWARD" -j CONTINUE
ebtables-legacy -t filter -A OUTPUT -p ipv4 --ip-proto udp --log-level 6 --log-ip --log-prefix "TRACE: eb:filter:OUTPUT" -j CONTINUE

在未加载br_netfilter的情况下, 我向8.8.8.8查询域名:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
TRACE: eb:broute:BROUTING IN=enp0s8 OUT= MAC source = 08:00:27:6d:c8:0b MAC dest = 08:00:27:9b:fa:c9 proto = 0x0800 IP SRC=192.168.12.192 IP DST=8.8.8.8, IP tos=0x00, IP proto=17 SPT=36289 DPT=53
TRACE: eb:nat:PREROUTING IN=enp0s8 OUT= MAC source = 08:00:27:6d:c8:0b MAC dest = 08:00:27:9b:fa:c9 proto = 0x0800 IP SRC=192.168.12.192 IP DST=8.8.8.8, IP tos=0x00, IP proto=17 SPT=36289 DPT=53
TRACE: eb:filter:INPUT IN=enp0s8 OUT= MAC source = 08:00:27:6d:c8:0b MAC dest = 08:00:27:9b:fa:c9 proto = 0x0800 IP SRC=192.168.12.192 IP DST=8.8.8.8, IP tos=0x00, IP proto=17 SPT=36289 DPT=53
** SUSPECT raw PREROUTING**IN=br0 OUT= MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=24626 PROTO=UDP SPT=36289 DPT=53 LEN=46
** SUSPECT mangle PREROUTING*IN=br0 OUT= MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=24626 PROTO=UDP SPT=36289 DPT=53 LEN=46
** SUSPECT nat PREROUTING**IN=br0 OUT= MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=24626 PROTO=UDP SPT=36289 DPT=53 LEN=46 MARK=0x162
** SUSPECT mangle INPUT**IN=br0 OUT= MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=24626 PROTO=UDP SPT=36289 DPT=53 LEN=46 MARK=0x162
** SUSPECT filter INPUT**IN=br0 OUT= MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=24626 PROTO=UDP SPT=36289 DPT=53 LEN=46 MARK=0x162
** SUSPECT nat INPUT**IN=br0 OUT= MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=24626 PROTO=UDP SPT=36289 DPT=53 LEN=46 MARK=0x162
** SUSPECT raw OUTPUT**IN= OUT=enp0s3 SRC=10.0.2.15 DST=10.91.0.1 LEN=57 TOS=0x00 PREC=0x00 TTL=64 ID=56764 DF PROTO=UDP SPT=45711 DPT=53 LEN=37
** SUSPECT raw OUTPUT**IN= OUT=enp0s3 SRC=10.0.2.15 DST=10.91.0.1 LEN=57 TOS=0x00 PREC=0x00 TTL=64 ID=56765 DF PROTO=UDP SPT=59060 DPT=53 LEN=37
** SUSPECT raw PREROUTING**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=114 TOS=0x00 PREC=0x00 TTL=64 ID=1722 PROTO=UDP SPT=53 DPT=45711 LEN=94
** SUSPECT mangle PREROUTING*IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=114 TOS=0x00 PREC=0x00 TTL=64 ID=1722 PROTO=UDP SPT=53 DPT=45711 LEN=94
** SUSPECT mangle INPUT**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=114 TOS=0x00 PREC=0x00 TTL=64 ID=1722 PROTO=UDP SPT=53 DPT=45711 LEN=94
** SUSPECT filter INPUT**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=114 TOS=0x00 PREC=0x00 TTL=64 ID=1722 PROTO=UDP SPT=53 DPT=45711 LEN=94
** SUSPECT raw PREROUTING**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=73 TOS=0x00 PREC=0x00 TTL=64 ID=1723 PROTO=UDP SPT=53 DPT=59060 LEN=53
** SUSPECT mangle PREROUTING*IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=73 TOS=0x00 PREC=0x00 TTL=64 ID=1723 PROTO=UDP SPT=53 DPT=59060 LEN=53
** SUSPECT mangle INPUT**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=73 TOS=0x00 PREC=0x00 TTL=64 ID=1723 PROTO=UDP SPT=53 DPT=59060 LEN=53
** SUSPECT filter INPUT**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=73 TOS=0x00 PREC=0x00 TTL=64 ID=1723 PROTO=UDP SPT=53 DPT=59060 LEN=53
** SUSPECT raw OUTPUT**IN= OUT=br0 SRC=8.8.8.8 DST=192.168.12.192 LEN=98 TOS=0x00 PREC=0x00 TTL=64 ID=40027 DF PROTO=UDP SPT=53 DPT=36289 LEN=78
TRACE: eb:nat:OUTPUT IN= OUT=enp0s8 MAC source = 08:00:27:9b:fa:c9 MAC dest = 08:00:27:6d:c8:0b proto = 0x0800 IP SRC=8.8.8.8 IP DST=192.168.12.192, IP tos=0x00, IP proto=17 SPT=53 DPT=36289
TRACE: eb:filter:OUTPUT IN= OUT=enp0s8 MAC source = 08:00:27:9b:fa:c9 MAC dest = 08:00:27:6d:c8:0b proto = 0x0800 IP SRC=8.8.8.8 IP DST=192.168.12.192, IP tos=0x00, IP proto=17 SPT=53 DPT=36289
TRACE: eb:nat:POSTROUTING IN= OUT=enp0s8 MAC source = 08:00:27:9b:fa:c9 MAC dest = 08:00:27:6d:c8:0b proto = 0x0800 IP SRC=8.8.8.8 IP DST=192.168.12.192, IP tos=0x00, IP proto=17 SPT=53 DPT=36289

可以看到包先过了ebtables规则, 然后转入了iptables规则, 也就是包按层级从下到上. 此时的tproxy是正常工作的, 可能你会感到疑惑, 为什么本机向10.91.0.1发送了UDP. 其实是因为我使用了VPN, 所以这是在解析VPN服务器的域名, UDP的转发走的是VPN的TCP通道.
接着我加载了br_netfilter, 观察日志:

1
2
3
4
5
6
7
8
9
TRACE: eb:broute:BROUTING IN=enp0s8 OUT= MAC source = 08:00:27:6d:c8:0b MAC dest = 08:00:27:9b:fa:c9 proto = 0x0800 IP SRC=192.168.12.192 IP DST=8.8.8.8, IP tos=0x00, IP proto=17 SPT=36003 DPT=53
TRACE: eb:nat:PREROUTING IN=enp0s8 OUT= MAC source = 08:00:27:6d:c8:0b MAC dest = 08:00:27:9b:fa:c9 proto = 0x0800 IP SRC=192.168.12.192 IP DST=8.8.8.8, IP tos=0x00, IP proto=17 SPT=36003 DPT=53
** SUSPECT raw PREROUTING**IN=br0 OUT= PHYSIN=enp0s8 MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=35740 PROTO=UDP SPT=36003 DPT=53 LEN=46
** SUSPECT mangle PREROUTING*IN=br0 OUT= PHYSIN=enp0s8 MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=35740 PROTO=UDP SPT=36003 DPT=53 LEN=46
** SUSPECT nat PREROUTING**IN=br0 OUT= PHYSIN=enp0s8 MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=35740 PROTO=UDP SPT=36003 DPT=53 LEN=46 MARK=0x162
TRACE: eb:filter:INPUT IN=enp0s8 OUT= MAC source = 08:00:27:6d:c8:0b MAC dest = 08:00:27:9b:fa:c9 proto = 0x0800 IP SRC=192.168.12.192 IP DST=8.8.8.8, IP tos=0x00, IP proto=17 SPT=36003 DPT=53
** SUSPECT mangle INPUT**IN=br0 OUT= PHYSIN=enp0s8 MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=35740 PROTO=UDP SPT=36003 DPT=53 LEN=46 MARK=0x162
** SUSPECT filter INPUT**IN=br0 OUT= PHYSIN=enp0s8 MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=35740 PROTO=UDP SPT=36003 DPT=53 LEN=46 MARK=0x162
** SUSPECT nat INPUT**IN=br0 OUT= PHYSIN=enp0s8 MAC=08:00:27:9b:fa:c9:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=35740 PROTO=UDP SPT=36003 DPT=53 LEN=46 MARK=0x162

这是什么情况??? 数据包在两个层级之间跳来跳去??? 我开始搜索相关资料, 原来这是br_netfilter提供的透明防火墙功能, 使得三层规则可以作用在二层上. 因为桥接的端口之间发送包是被网桥直接转走的, 不会走到本机的iptables中, 而br_netfilter使得ebtables主动调用iptables规则, 这样所有的包都能接受过滤.
谜底揭开了, 我看到的日志显示包已经到了filter表的INPUT, 可实际上它还没有过IP层路由选择, 它此时在二层ebtables的filter表中, 只不过它主动的调用了iptables. 也就是说这个包没有通过路由进入本地协议栈, 它被丢弃或者转走了. 大概是因为iptables打上的fwmark, 在ebtables中丢失了, 导致进行路由选择时无法命中我们添加的路由规则.
于是我看阅读ebtables的文档, 文档中说道, 在broute表中将流量DROP, 会使得包进入Network Layer处理, 这样就是图中broute有个箭头指到上层的含义.

1
ebtables-legacy -t broute -A BROUTING -i enp0s8 -p ipv4 --ip-proto udp -j redirect --redirect-target DROP

我执行上面的命令后, 再执行查询操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
TRACE: eb:broute:BROUTING IN=enp0s8 OUT= MAC source = 08:00:27:6d:c8:0b MAC dest = 08:00:27:9b:fa:c9 proto = 0x0800 IP SRC=192.168.12.192 IP DST=8.8.8.8, IP tos=0x00, IP proto=17 SPT=47382 DPT=53
** SUSPECT raw PREROUTING**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=25647 PROTO=UDP SPT=47382 DPT=53 LEN=46
** SUSPECT mangle PREROUTING*IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=25647 PROTO=UDP SPT=47382 DPT=53 LEN=46
** SUSPECT nat PREROUTING**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=25647 PROTO=UDP SPT=47382 DPT=53 LEN=46 MARK=0x162
** SUSPECT mangle INPUT**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=25647 PROTO=UDP SPT=47382 DPT=53 LEN=46 MARK=0x162
** SUSPECT filter INPUT**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=25647 PROTO=UDP SPT=47382 DPT=53 LEN=46 MARK=0x162
** SUSPECT nat INPUT**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=8.8.8.8 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=25647 PROTO=UDP SPT=47382 DPT=53 LEN=46 MARK=0x162
** SUSPECT raw OUTPUT**IN= OUT=enp0s3 SRC=10.0.2.15 DST=10.91.0.1 LEN=57 TOS=0x00 PREC=0x00 TTL=64 ID=40282 DF PROTO=UDP SPT=54985 DPT=53 LEN=37
** SUSPECT raw OUTPUT**IN= OUT=enp0s3 SRC=10.0.2.15 DST=10.91.0.1 LEN=57 TOS=0x00 PREC=0x00 TTL=64 ID=40283 DF PROTO=UDP SPT=37997 DPT=53 LEN=37
** SUSPECT raw PREROUTING**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=73 TOS=0x00 PREC=0x00 TTL=64 ID=4012 PROTO=UDP SPT=53 DPT=37997 LEN=53
** SUSPECT mangle PREROUTING*IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=73 TOS=0x00 PREC=0x00 TTL=64 ID=4012 PROTO=UDP SPT=53 DPT=37997 LEN=53
** SUSPECT mangle INPUT**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=73 TOS=0x00 PREC=0x00 TTL=64 ID=4012 PROTO=UDP SPT=53 DPT=37997 LEN=53
** SUSPECT filter INPUT**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=73 TOS=0x00 PREC=0x00 TTL=64 ID=4012 PROTO=UDP SPT=53 DPT=37997 LEN=53
** SUSPECT raw PREROUTING**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=114 TOS=0x00 PREC=0x00 TTL=64 ID=4018 PROTO=UDP SPT=53 DPT=54985 LEN=94
** SUSPECT mangle PREROUTING*IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=114 TOS=0x00 PREC=0x00 TTL=64 ID=4018 PROTO=UDP SPT=53 DPT=54985 LEN=94
** SUSPECT mangle INPUT**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=114 TOS=0x00 PREC=0x00 TTL=64 ID=4018 PROTO=UDP SPT=53 DPT=54985 LEN=94
** SUSPECT filter INPUT**IN=enp0s3 OUT= MAC=08:00:27:4f:1e:2b:52:54:00:12:35:02:08:00 SRC=10.91.0.1 DST=10.0.2.15 LEN=114 TOS=0x00 PREC=0x00 TTL=64 ID=4018 PROTO=UDP SPT=53 DPT=54985 LEN=94
** SUSPECT raw OUTPUT**IN= OUT=br0 SRC=8.8.8.8 DST=192.168.12.192 LEN=98 TOS=0x00 PREC=0x00 TTL=64 ID=26403 DF PROTO=UDP SPT=53 DPT=47382 LEN=78
TRACE: eb:nat:OUTPUT IN= OUT=enp0s8 MAC source = 08:00:27:9b:fa:c9 MAC dest = 08:00:27:6d:c8:0b proto = 0x0800 IP SRC=8.8.8.8 IP DST=192.168.12.192, IP tos=0x00, IP proto=17 SPT=53 DPT=47382
TRACE: eb:filter:OUTPUT IN= OUT=enp0s8 MAC source = 08:00:27:9b:fa:c9 MAC dest = 08:00:27:6d:c8:0b proto = 0x0800 IP SRC=8.8.8.8 IP DST=192.168.12.192, IP tos=0x00, IP proto=17 SPT=53 DPT=47382
TRACE: eb:nat:POSTROUTING IN= OUT=enp0s8 MAC source = 08:00:27:9b:fa:c9 MAC dest = 08:00:27:6d:c8:0b proto = 0x0800 IP SRC=8.8.8.8 IP DST=192.168.12.192, IP tos=0x00, IP proto=17 SPT=53 DPT=47382

可以看到包的确被重定向到了Network Layer, tproxy也正常工作了.

尾声

正当我以为一切都结束了的时候, 我打开了斗鱼直播, 可浏览器却显示网络异常, 我又上不了网了. 我在终端进行ping测试, IP可以ping通而域名不行, 这样看来是DNS的问题.

1
dig baidu.com @192.168.12.1

命令卡住了, 难道发往本地的包重定向到Network Layer就被拒收了? 我又开始观察ebtables/iptables的日志输出:

1
2
3
4
5
6
7
TRACE: eb:broute:BROUTING IN=enp0s8 OUT= MAC source = 08:00:27:6d:c8:0b MAC dest = 08:00:27:9b:fa:c9 proto = 0x0800 IP SRC=192.168.12.192 IP DST=192.168.12.1, IP tos=0x00, IP proto=17 SPT=34741 DPT=53
** SUSPECT raw PREROUTING**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=192.168.12.1 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=7347 PROTO=UDP SPT=34741 DPT=53 LEN=46
** SUSPECT mangle PREROUTING*IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=192.168.12.1 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=7347 PROTO=UDP SPT=34741 DPT=53 LEN=46
** SUSPECT nat PREROUTING**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=192.168.12.1 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=7347 PROTO=UDP SPT=34741 DPT=53 LEN=46
** SUSPECT mangle INPUT**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=192.168.12.1 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=7347 PROTO=UDP SPT=34741 DPT=53 LEN=46
** SUSPECT filter INPUT**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=192.168.12.1 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=7347 PROTO=UDP SPT=34741 DPT=53 LEN=46
** SUSPECT nat INPUT**IN=enp0s8 OUT= MAC=08:00:27:ae:7d:59:08:00:27:6d:c8:0b:08:00 SRC=192.168.12.192 DST=192.168.12.1 LEN=66 TOS=0x00 PREC=0x00 TTL=64 ID=7347 PROTO=UDP SPT=34741 DPT=53 LEN=46

数据包已经进入了INPUT, 这次的确是进入了本地协议栈, 但是需要注意数据包的IN网卡已经从桥接网卡bro变成了真实网卡端口enp0s8. 难道是因为这个改变, 而被协议栈拒收? 还是因为dnsmasq没有bind该网卡?
我手动使用nc进行测试:

1
nc -u -lp 3000

使用nc监听软路由上3000端口, 然后在电脑上执行:

1
dig baidu.com @192.168.12.1 -p 3000

如果数据能被接收, nc会打印出DNS查询报文, 结果令我诧异, nc打印出了报文内容. 那么看来即使IN interface被修改, 数据包依旧能够被正确接收.
最后我使用strace追踪dnsmasq的系统调用:

1
strace /sbin/dnsmasq

执行查询后, 我发现dnsmasq接收到了报文, 只是它似乎获取了网卡名称bro与enp0s8, 应该是它进行了网卡校验, 认为请求不应该由enp0s8流入, 所以没有进行回应.

总结

为了不影响Docker的正常使用, 我没有将所有Link Layer流量进行重定向, 放行了一些常见内网IP网段.
所以我最后的ebtables/iptables配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/bin/sh
# tcp
iptables -t nat -N CLASH
iptables -t nat -A CLASH -d 127.0.0.0/8 -j RETURN
iptables -t nat -A CLASH -d 10.0.0.0/8 -j RETURN
iptables -t nat -A CLASH -d 169.254.0.0/16 -j RETURN
iptables -t nat -A CLASH -d 192.168.0.0/16 -j RETURN
iptables -t nat -A CLASH -d 224.0.0.0/4 -j RETURN
iptables -t nat -A CLASH -d 240.0.0.0/4 -j RETURN
iptables -t nat -A CLASH -d 172.16.0.0/12 -j RETURN
iptables -t nat -A CLASH -d 255.255.255.255 -j RETURN
iptables -t nat -A CLASH -p tcp -j REDIRECT --to-ports 7892
iptables -t nat -A PREROUTING -p tcp -j CLASH

# udp
ip rule add fwmark 0x162 table 0x162
ip route add local 0.0.0.0/0 dev lo table 0x162
iptables -t mangle -N CLASH
iptables -t mangle -A CLASH -d 127.0.0.0/8 -j RETURN
iptables -t mangle -A CLASH -d 10.0.0.0/8 -j RETURN
iptables -t mangle -A CLASH -d 169.254.0.0/16 -j RETURN
iptables -t mangle -A CLASH -d 192.168.0.0/16 -j RETURN
iptables -t mangle -A CLASH -d 224.0.0.0/4 -j RETURN
iptables -t mangle -A CLASH -d 240.0.0.0/4 -j RETURN
iptables -t mangle -A CLASH -d 172.16.0.0/12 -j RETURN
iptables -t mangle -A CLASH -d 255.255.255.255 -j RETURN
iptables -t mangle -A CLASH -p udp -j TPROXY --on-port 7892 --tproxy-mark 0x162
iptables -t mangle -A PREROUTING -p udp -j CLASH

ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp --ip-destination 127.0.0.0/8 -j ACCEPT
ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp --ip-destination 10.0.0.0/8 -j ACCEPT
ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp --ip-destination 169.254.0.0/16 -j ACCEPT
ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp --ip-destination 192.168.0.0/16 -j ACCEPT
ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp --ip-destination 224.0.0.0/4 -j ACCEPT
ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp --ip-destination 240.0.0.0/4 -j ACCEPT
ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp --ip-destination 172.16.0.0/12 -j ACCEPT
ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp --ip-destination 255.255.255.255 -j ACCEPT
ebtables-legacy -t broute -A BROUTING -p ipv4 --ip-proto udp -j redirect --redirect-target DROP

有一点需要注意, ebtables规则不需要”-i name”指定网卡名称, 因为理论上docker容器与个人电脑(连接软路由LAN口)处于同一网络层面. 不管是LAN口网桥还是docker网桥, 都需要适配该规则, 否则UDP透明代理将失败.

软路由折腾记录一

透明代理

在购买了小主机J1900之后, 我在上面安装了OpenWrt, 一切都很正常, 我搭建了Clash, 配置了iptables使TCP/UDP走透明代理.

TCP透明代理

TCP透明代理比较简单, 因为TCP有状态, 所以透明代理成本较低, iptables提供重定向操作.

1
iptables -t nat -A CLASH -p tcp -j REDIRECT --to-ports 7892

规则将tcp所有流量重定向到本地7892, iptables只需要在SYN包时记录下原始目标地址, 7892端口accept之后通过”SO_ORIGINAL_DST”即可拿到原始目标地址.

UDP透明代理

UDP没有状态, 所以想和TCP进行透明代理是不可能的, 每个连接都像TCP一样保存原始目标地址, 成本较大.

1
2
3
iptables -t mangle -A CLASH -p udp -j TPROXY --on-port 7892 --tproxy-mark 0x111
ip rule add fwmark 0x111 table 0x162
ip route add local 0.0.0.0/0 dev lo table 0x162

UDP透明代理需要三条规则, 第一条使用iptables提供的tproxy模块, 将udp流量重定向到本地7892端口. 比较有意思的是tproxy不修改报文, 但是依旧可以完成重定向任务. 在报文匹配这条规则时, tproxy会查找本地是否开启7892端口, 并且该socket是否设置了”IP_TRANSPARENT”属性, 如果符合则将socket与报文做关联, 修改报文的结构体(skb->sock=sk).
但是在报文不做修改的情况下, 因为目的地址不是本机, 所以路由选择的时候, 该报文会被forward而不会进入本地协议栈进行处理, 那么关联socket的修改就没有意义了. 所以后面两条路由规则, 就是将报文送入本地协议栈进行处理.
tproxy会给每个匹配的报文打上标记”0x111”, 第二条路由规则表示带有”0x111”标记的报文使用”0x162”路由表. 第三条创建”0x162”路由表, 表里的规则将流量本地回环网卡lo, 这样流量顺利进入协议栈, 并且关联修改让它顺利被7892端口接收.

example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include <stdio.h>
#include <netinet/in.h>
#include <memory.h>
#include <arpa/inet.h>

int main() {
int server_fd, ret;
struct sockaddr_in ser_addr;

server_fd = socket(AF_INET, SOCK_DGRAM, 0);

if(server_fd < 0) {
printf("create socket fail!\n");
return -1;
}

int enable = 1;

if (setsockopt(server_fd, SOL_IP, IP_RECVORIGDSTADDR, (const char*)&enable, sizeof(enable)) != 0) {
printf("set socket op fail!\n");
return -1;
}

if (setsockopt(server_fd, SOL_IP, IP_TRANSPARENT, (const char*)&enable, sizeof(enable)) != 0) {
printf("set socket op fail!\n");
return -1;
}

memset(&ser_addr, 0, sizeof(ser_addr));

ser_addr.sin_family = AF_INET;
ser_addr.sin_addr.s_addr = htonl(INADDR_ANY);
ser_addr.sin_port = htons(7892);

ret = bind(server_fd, (struct sockaddr*)&ser_addr, sizeof(ser_addr));
if(ret < 0)
{
printf("socket bind fail!\n");
return -1;
}

char buffer[256];
unsigned char bytes[16*1024];

struct sockaddr_in srcIpAddr, dstIpAddr;
int dstPort;

struct iovec iov;

iov.iov_base = bytes;
iov.iov_len = sizeof(bytes)-1;

struct msghdr mh;

mh.msg_name = &srcIpAddr;
mh.msg_namelen = sizeof(struct sockaddr_in);
mh.msg_control = buffer;
mh.msg_controllen = 100;
mh.msg_iovlen = 1;
mh.msg_iov = &iov;

int res = recvmsg(server_fd, &mh, 0);

if (res <= 0) {
printf("recv msg failed");
return -1;
}

for(struct cmsghdr *msg = CMSG_FIRSTHDR(&mh); msg != NULL; msg = CMSG_NXTHDR(&mh, msg))
{
if(msg->cmsg_level != SOL_IP || msg->cmsg_type != IP_ORIGDSTADDR)
continue;

memcpy(&dstIpAddr, CMSG_DATA(msg), sizeof(struct sockaddr_in));
dstPort = ntohs(dstIpAddr.sin_port);

char str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &(dstIpAddr.sin_addr), str, INET_ADDRSTRLEN);

printf("%s:%d", str, dstPort);
}

return 0;
}

Docker

简单的翻墙使用, 让我觉得浪费了J1900的性能, 我突发奇想, 我想在上面装Docker! 当我在google反复搜索, 貌似出来的答案都是Docker里面装OpenWrt, 没有人在OpenWrt上装Docker, 唯一有价值的帖子是这篇Docker engine on OpenWrt.
Docker现在只存在于master分支, 如果想用需要自己编译, 于是我开始搭环境进行编译.

1
2
3
4
5
6
7
git clone https://git.openwrt.org/openwrt/openwrt.git
cd openwrt
./scripts/feeds update -a
./scripts/feeds install -a
make menuconfig
make -j8 download
make -j8

编译会遇到很多坑, 也和网络环境有关, 因为编译会下载大量的源码, 所以我全程使用了proxychains.

组件

make menuconfig时会让你选择组件, 此时我很自然的只勾选了docker-ce, 然而编译的结果是整个系统只有docker-ce! 自己编译是不会有软件源的, 因为内核版本不兼容, 你从OpenWrt的Snapshot源(官方dev源, 每天自动编译)下载的包是装不上, 所以连iptables-mod-tproxy都没有.
我勾选了tproxy模块重新编译后, 发现自带的web UI没有, 我开始暴躁了起来. 最后我发现官方发出的稳定版本会附上编译时的配置, 所以在make menuconfig之前先下载下来:

1
curl https://downloads.openwrt.org/releases/19.07.3/targets/x86/64/config.buildinfo > .config

再进行make menuconfig会在官方的配置上进行定制, 我勾选了所需要的模块, 编译进行的很顺利.

代理

我习惯使用proxychains进行代理, 它使用LD_PRELOAD进行socket相关的hook, 支持TCP/UDP. 不过在编译Python3时, 出现了错误:

1
2
Fatal: You must get working getaddrinfo() function.
or you can specify "--disable-ipv6".

搜索之后发现是proxychains的问题, 但是我没有详细探究原因, 我转而使用:

1
2
export http_proxy="http://127.0.0.1:8080"
export https_proxy="http://127.0.0.1:8080"

因为编译的下载大多都会使用该环境变量, 所以也没有发生下载缓慢的问题.

问题

之前的文章已经写了详细安装过程, 这里不赘述了, 使用时发现了两个问题.

  • 无法使用官方源, 要安装screen等软件需要自行编译
  • Docker可正常使用, tproxy无法正常工作

第一个问题完全可以忍受, 因为编译后的目录结构与官方源完全相同, 所以可以上传到自己的VPS, 然后修改opkg配置文件, 使用自建源. 第二个问题相当诡异, 调试了几天依旧找不到原因, 我一度以为是OpenWrt的bug, 甚至发朋友圈怒斥之.

转变

最后我起了个念头, 我想把系统换成Debian, 通过DIV将它改造成软路由. 大概想了一下, 无非是搭建DNS/DHCP, 桥接网卡、开启ip中转, 设置iptables的DNAT规则. 我重新燃起了热情, 为即将可以使用舒适稳定的系统而躁动不安, 此时的我显然没有意识到有一个bug在前方等待着我.

JVM Probe技术

原理

JVM提供动态attach机制, 支持动态注入jar包.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package com.security.smith;

import com.sun.tools.attach.AgentInitializationException;
import com.sun.tools.attach.AgentLoadException;
import com.sun.tools.attach.AttachNotSupportedException;
import com.sun.tools.attach.VirtualMachine;

import java.io.IOException;

public class AgentLoader {
public static void main(String[] args) {
if (args.length != 2) {
System.out.println("usage: program <pid> <jar>");
return;
}

String pid = args[0];
String jarPath = args[1];

System.out.println("pid: " + pid + " jar: " + jarPath);

try {
VirtualMachine vm = VirtualMachine.attach(pid);

vm.loadAgent(jarPath);
vm.detach();
} catch (AttachNotSupportedException | IOException | AgentLoadException | AgentInitializationException e) {
e.printStackTrace();
}
}
}

使用方法如上, attach的过程分为3步:

  • 检查临时目录下是否有.java_pid{pid} unix socket文件
  • 没有则创建.attach_pid{pid}文件, 给目标进程发送信号3
  • 目标进程JVM虚拟机收到信号, 创建.java_pid{pid} unix socket文件

基于unix socket, 可以给JVM发送指令, 例如”load”指令可以加载.so文件, VirtualMachine.loadAgent也是基于load指令实现的. loadAgent内部发送”load”指令, 使JVM加载自带的”.so”, 动态库内部加载参数指定的jar包.
jar包的MANIFEST.MF需要提供必要参数:

1
2
3
4
Manifest-Version: 1.0
Agent-Class: com.security.smith.SmithAgent
Can-Retransform-Classes: true
Boot-Class-Path: SmithAgent.jar

Agent-Class指定入口Class, 该Class需要实现入口方法:

1
2
3
4
5
public class SmithAgent {
public static void agentmain(String agentArgs, Instrumentation inst) {

}
}

Hook

Instrumentation类提供一系列功能:

  1. ClassFileTransformer用于拦截类加载事件, 需要注册到Instrumentation
  2. Instrumentation.redefineClasses
    • 针对已加载的类, 舍弃原本的字节码, 替换为由用户提供的byte数组
    • 功能比较危险, 一般用于修复出错的字节码
  3. Instrumentation.retransformClasses
    • 针对已加载的类, 重新调用所有已注册的ClassFileTransformer的transform方法, 两个场景
    • 在执行premain和agentmain方法前, JVM已经加载了不少类
      • 而这些类的加载事件并没有被拦截并执行相关的注入逻辑
    • 定义了多个Java Agent, 多个注入的情况, 可能需要移除其中的部分注入
      • 调用Instrumentation.removeTransformer去除某个注入类后, 可以调用retransformClasses
      • 重新从原始byte数组开始进行注入
  4. Java Agent的功能是通过JVMTI Agent(C Agent), JVMTI是一个事件驱动的工具实现接口
    • 通常会在C Agent加载后的方法入口Agent_OnLoad处注册各种事件的钩子方法
    • 当JVM触发这些事件时, 便会调用对应的钩子方法
    • 例如可以为JVMTI中的ClassFileLoadHook事件设置钩子, 从而在C层面拦截所有的类加载事件

使用Instrumentation可以添加Class拦截器, 在Class加载时能够插入或修改字节码, 达到控制整个Class的目的. 对于已经加载的Class, 可以使用retransformClasses强行重载Class, 拦截器便能对重载的Class进行拦截.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SmithProbe implements ClassFileTransformer {
public void start() {
inst.addTransformer(this, true);

try {
inst.retransformClasses(Runtime.class);

} catch (UnmodifiableClassException e) {
e.printStackTrace();
}
}

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
return null;
}
}

classfileBuffer就是目标Class的字节码流, 使用asm等库可以解析字节码并篡改, 返回篡改后的字节码流即可篡改Class, 返回null则示意JVM加载原本的字节码.

JVM函数调用

JVM函数调用基于栈, 例如调用Runtime.exec, 需要依次将类实例引用, exec的函数参数推出栈, 伪代码如下:

1
2
3
4
5
6
# 假定类实例存在寄存器1中, 将寄存器1的值推入栈
ALOAD 1
# 将字符串"ls"推入栈
LDC "ls"
# 调用函数, 因为Java支持重载, 需要指定参数签名
INVOKEVIRTUAL Runtime.exec (Ljava/lang/String;)Ljava/lang/Process;

而进入函数之后, JVM自动根据参数个数, 帮方法将参数放入寄存器(0+), 所以方法内获取参数:

1
2
3
4
# 获取类实例, 静态方法无实例, 所以0寄存器保存的是第一个参数
ALOAD 0
# 获取字符串参数"ls"
ALOAD 1

设计

根据上面分析, 要将函数调用的参数信息传出, 只需要在每个函数的开头插入一段字节码, 但是字节码需要动态生成. 没有一段字节码能够搞定所有函数, 因为每个函数的参数个数以及类型都不同. 所以Hook还是需要我们设定好函数的参数类型以及签名.
例如针对Runtime.exe(String), 我们设定函数信息:

1
java/lang/Runtime VIRTUAL_METHOD_TYPE exec {"string"}

那么在对Class进行拦截时, 我们可以根据上面的设定Hook函数, 根据VIRTUAL_METHOD_TYPE我们知道exec的参数从寄存器1开始, 根据{“string”}我们知道参数有1个, 并且是string类型, 这时候我们只需要插入一段字节码在exec的开头:

1
2
3
# 获取参数
ALOAD 1
INVOKESTATIC com/security/smith/SmithProbe.trace (Ljava/lang/String;)V

两个字节码就可以获取函数调用信息, 第一行将参数推入栈, 第二行调用我们编写好的方法:

1
2
3
4
5
public class SmithProbe implements ClassFileTransformer {
public static void trace(String arg) {

}
}

以上只是一个简单的例子, trace方法我们不能简单的写定参数, 因为每个Hook的函数传进来的参数都不一样. 所以我会在Hook时, 将目标函数的参数推入Object[], 然后传到trace里面来. 另外需要标识是什么类的什么方法调用的trace, 可以在生成字节码时, 给每个调用分配一个id.

1
2
3
4
5
public class SmithProbe implements ClassFileTransformer {
public void trace(int classID, int methodID, Object... args) {

}
}

上面是我定义的函数原型, 仅供参考.

搭建OpenWrt软路由

起因

一直以来, 都想在路由器上搭建机场, 不用在每台设备上都安装小飞机. 看了一下路由器, 基本上都是华硕路由器刷梅林固件, 因为价格过高以及我大部分时间使用有线网络的原因, 购买一个新的路由器并没有必要.
无意中看到了软路由这个词, 搜索之后得知, 软路由其实就是基于CPU散片组装的小型PC, 在上面安装OpenWrt等系统, 就可以进行路由转发. 在上面有很多插件, 可以方便的搭建透明代理. 于是当天在淘宝上购买了基于J1900的软路由, 到家之后立刻开始折腾.

安装

在OpenWrt官网下载了img镜像, 准备好Arch Linux的PE盘, 选择Arch是因为镜像小, 下载和烧录迅速. 把软路由接好显示器, 插入U盘, 按下电源键, 默认就优先进入了U盘引导. Arch Linux启动后, 再插入包含OpenWrt镜像的U盘, 或者直接使用curl下载.

1
dd if=openwrt.img of=/dev/sda

使用dd命令将镜像直接写入软路由硬盘, 此时如果重启就可以直接进入OpenWrt系统了. 但这里有个坑需要注意, 不管你的软路由的硬盘有多大, 重启后你就会发现rootfs只有200M+的空间. 因为dd写入镜像, 分区表等信息都和镜像一样, 镜像的ext4分区默认只有200M+. 所以要使用PE系统重建分区, 因为OpenWrt运行时不可以对自身的分区进行修改.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 对sda进行分区扩展
root@OpenWrt:~# sudo fdisk /dev/sda
Welcome to fdisk (util-linux 2.32).
Changes will remain in memory only, until you decide to write them.
Be careful before using the write command.

# 输入 p 显示分区信息
Command (m for help): p
Disk /dev/sda: 7.2 GiB, 7751073792 bytes, 15138816 sectors
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xcbad8a62

Device Boot Start End Sectors Size Id Type
/dev/sda1 * 512 33279 32768 16M 83 Linux
/dev/sda2 33792 558079 524288 256M 83 Linux
# 输入 d 删除 第二个分区,这里记录下第二个分区的起点是33792
Command (m for help): d
Partition number (1,2, default 2):

Partition 2 has been deleted.
# 输入 n 创建新的分区
Command (m for help): n
Partition type
p primary (1 primary, 0 extended, 3 free)
e extended (container for logical partitions)
# 输入 p 确认是主分区
Select (default p): p
# 输入分区id 用默认的2
Partition number (2-4, default 2): 2
# 输入 分区起始位置(就是原来分区2起始的位置)
First sector (33280-15138815, default 34816): 33792
# 这里直接回车,默认选择终止位置就是硬盘的末尾
Last sector, +sectors or +size{K,M,G,T,P} (33792-15138815, default 15138815):

Created a new partition 2 of type 'Linux' and of size 7.2 GiB.
Partition #2 contains a ext4 signature.

Do you want to remove the signature? [Y]es/[N]o: n
# 输入 w ,写入硬盘, 这里可能会有一个warning,忽略即可
Command (m for help): w

The partition table has been altered.
Syncing disks.

此时分区大小扩展了, 但是上面的ext4还没有扩容:

1
2
resize2fs /dev/sda2
e2fsck -f /dev/sda2

执行上面的命令完成rootfs扩容, 重启即可进入OpenWrt.

配置

进入系统后执行:

1
cat /etc/config/network

查看网卡配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
config interface 'loopback'
option ifname 'lo'
option proto 'static'
option ipaddr '127.0.0.1'
option netmask '255.0.0.0'

config globals 'globals'
option ula_prefix 'fddb:f68c:5cd8::/48'

config interface 'lan'
option type 'bridge'
option ifname 'eth0'
option proto 'static'
option ipaddr '192.168.1.1'
option netmask '255.255.255.0'
option ip6assign '60'

config interface 'wan'
option ifname 'eth1'
option proto 'dhcp'

config interface 'wan6'
option ifname 'eth1'
option proto 'dhcpv6'

可以看到wan口配置在eth1, 也就是第二个网线插口, 从光猫牵线插入wan口即可获取动态IP, 此时OpenWrt已经成功联网. 另外eth0也就是第一个网线插口配置成了lan, 将电脑或路由器插上去即可获取IP. 此时用电脑浏览器访问192.168.1.1(可手动修改配置), 登录OpenWrt配置页面.

Clash

安装了clash之后, 可以顺利的科学上网, 可是当我执行:

1
2
3
PING baidu.com (198.18.1.115) 56(84) bytes of data.
--- baidu.com ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 4ms

域名baidu.com被解析到了一个保留地址, 可是此时我的系统可以正常联网, 于是我开始探索这种被称为Fake IP的机制. Clash自带DNS服务器, 监听127.0.0.1:5300, 通过设置dnsmasq的server字段, 将所有DNS请求转发到自身服务.
然后通过建立一个虚拟IP与实际域名的映射表, 随机返回一个IP(198.18.0.1/16). 客户端收到响应后, 与该IP建立TCP/UDP连接, 而Clash通过添加IPTables规则, 将目标IP属于198.18.0.1/16的请求转发到Clash的Redir Port(7892). 此时Clash可以查询IP对应的域名, 进行透明代理.