[翻译_by_gpt] 一个简单的 ELF
URL 来源: https://4zm.org/2024/12/25/a-simple-elf.html
Markdown 内容:
让我们为 Linux 编写一个简单的程序。能有多难呢?嗯,简单是复杂的反义词,而不是困难的反义词,创建一个简单的东西出奇地难。当我们摆脱标准库、所有现代安全功能、调试信息和错误处理机制的复杂性时,还剩下什么?
• • •
让我们从一些复杂的东西开始:
#include <stdio.h>
int main() {
printf("Hello Simplicity!\n");
}
等等,什么?!看起来并不复杂,对吧……嗯,让我们编译它并看看:
$ gcc -o hello hello.c
$ ./hello
Hello Simplicity!
看起来还是很简单,对吧?错了!虽然这可能是熟悉的领域并且容易理解,但程序远非简单。让我们看看幕后。
$ objdump -t hello
hello: file format elf64-x86-64
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 Scrt1.o
000000000000038c l O .note.ABI-tag 0000000000000020 __abi_tag
0000000000000000 l df *ABS* 0000000000000000 crtstuff.c
0000000000001090 l F .text 0000000000000000 deregister_tm_clones
00000000000010c0 l F .text 0000000000000000 register_tm_clones
0000000000001100 l F .text 0000000000000000 __do_global_dtors_aux
0000000000004010 l O .bss 0000000000000001 completed.0
0000000000003dc0 l O .fini_array 0000000000000000 __do_global_dtors_aux_fini_array_entry
0000000000001140 l F .text 0000000000000000 frame_dummy
0000000000003db8 l O .init_array 0000000000000000 __frame_dummy_init_array_entry
0000000000000000 l df *ABS* 0000000000000000 hello.c
0000000000000000 l df *ABS* 0000000000000000 crtstuff.c
00000000000020f8 l O .eh_frame 0000000000000000 __FRAME_END__
0000000000000000 l df *ABS* 0000000000000000
0000000000003dc8 l O .dynamic 0000000000000000 _DYNAMIC
0000000000002018 l .eh_frame_hdr 0000000000000000 __GNU_EH_FRAME_HDR
0000000000003fb8 l O .got 0000000000000000 _GLOBAL_OFFSET_TABLE_
0000000000000000 F *UND* 0000000000000000 __libc_start_main@GLIBC_2.34
0000000000000000 w *UND* 0000000000000000 _ITM_deregisterTMCloneTable
0000000000004000 w .data 0000000000000000 data_start
0000000000000000 F *UND* 0000000000000000 puts@GLIBC_2.2.5
0000000000004010 g .data 0000000000000000 _edata
0000000000001168 g F .fini 0000000000000000 .hidden _fini
0000000000004000 g .data 0000000000000000 __data_start
0000000000000000 w *UND* 0000000000000000 __gmon_start__
0000000000004008 g O .data 0000000000000000 .hidden __dso_handle
0000000000002000 g O .rodata 0000000000000004 _IO_stdin_used
0000000000004018 g .bss 0000000000000000 _end
0000000000001060 g F .text 0000000000000026 _start
0000000000004010 g .bss 0000000000000000 __bss_start
0000000000001149 g F .text 000000000000001e main
0000000000004010 g O .data 0000000000000000 .hidden __TMC_END__
0000000000000000 w *UND* 0000000000000000 _ITM_registerTMCloneTable
0000000000000000 w F *UND* 0000000000000000 __cxa_finalize@GLIBC_2.2.5
0000000000001000 g F .init 0000000000000000 .hidden _init
符号真多!实际上,就符号表而言,这个已经相当简洁了。任何非平凡的程序都会有更多的符号,但即便如此,这些符号都是干什么用的?我们只是打印一个字符串而已!
我们在地址 0x1149
的 .text
段中识别出了我们的 main
函数。但 printf
函数在哪里呢?
事实证明,对于不需要 printf
进行格式化工作的简单情况,GCC 会优化代码并用 libc 中更简单的 puts@GLIBC_2.2.5
替换它。由于符号是未定义的(*UND*
),所以地址全是零。当程序与动态库 libc.so 一起加载时,它将被解析。
0000000000001149 g F .text 000000000000001e main
0000000000000000 F *UND* 0000000000000000 puts@GLIBC_2.2.5
让我们继续挖掘。程序中有哪些段?我们唯一的数据是硬编码的字符串及其长度。我们肯定只需要一个 .text
段吧?让我们看看我们得到了什么:
$ objdump -h hello
hello: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .interp 0000001c 0000000000000318 0000000000000318 00000318 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
1 .note.gnu.property 00000030 0000000000000338 0000000000000338 00000338 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .note.gnu.build-id 00000024 0000000000000368 0000000000000368 00000368 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .note.ABI-tag 00000020 000000000000038c 000000000000038c 0000038c 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .gnu.hash 00000024 00000000000003b0 00000000000003b0 000003b0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
5 .dynsym 000000a8 00000000000003d8 00000000000003d8 000003d8 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .dynstr 0000008d 0000000000000480 0000000000000480 00000480 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
7 .gnu.version 0000000e 000000000000050e 000000000000050e 0000050e 2**1
CONTENTS, ALLOC, LOAD, READONLY, DATA
8 .gnu.version_r 00000030 0000000000000520 0000000000000520 00000520 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
9 .rela.dyn 000000c0 0000000000000550 0000000000000550 00000550 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
10 .rela.plt 00000018 0000000000000610 0000000000000610 00000610 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
11 .init 0000001b 0000000000001000 0000000000001000 00001000 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
12 .plt 00000020 0000000000001020 0000000000001020 00001020 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
13 .plt.got 00000010 0000000000001040 0000000000001040 00001040 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
14 .plt.sec 00000010 0000000000001050 0000000000001050 00001050 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
15 .text 00000107 0000000000001060 0000000000001060 00001060 2**4
CONTENTS, ALLOC, LOAD, READONLY, CODE
16 .fini 0000000d 0000000000001168 0000000000001168 00001168 2**2
CONTENTS, ALLOC, LOAD, READONLY, CODE
17 .rodata 00000011 0000000000002000 0000000000002000 00002000 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
18 .eh_frame_hdr 00000034 0000000000002014 0000000000002014 00002014 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
19 .eh_frame 000000ac 0000000000002048 0000000000002048 00002048 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
20 .init_array 00000008 0000000000003db8 0000000000003db8 00002db8 2**3
CONTENTS, ALLOC, LOAD, DATA
21 .fini_array 00000008 0000000000003dc0 0000000000003dc0 00002dc0 2**3
CONTENTS, ALLOC, LOAD, DATA
22 .dynamic 000001f0 0000000000003dc8 0000000000003dc8 00002dc8 2**3
CONTENTS, ALLOC, LOAD, DATA
23 .got 00000048 0000000000003fb8 0000000000003fb8 00002fb8 2**3
CONTENTS, ALLOC, LOAD, DATA
24 .data 00000010 0000000000004000 0000000000004000 00003000 2**3
CONTENTS, ALLOC, LOAD, DATA
25 .bss 00000008 0000000000004010 0000000000004010 00003010 2**0
ALLOC
26 .comment 0000002b 0000000000000000 0000000000000000 00003010 2**0
CONTENTS, READONLY
好的,这确实很复杂。不仅仅是一个简单的 .text
段。还有很多段。
这太多了,现在无法处理。程序到底从哪里开始?它从 main
开始,对吧?又错了!
$ objdump -f hello
hello: file format elf64-x86-64
architecture: i386:x86-64, flags 0x00000150:
HAS_SYMS, DYNAMIC, D_PAGED
start address 0x0000000000001060
“起始地址”(也称为入口点)是 _start
,而不是 main
。这个神秘的函数在 0x1060
必须以某种方式调用我们的 main
,但它来自哪里呢?
0000000000001060 g F .text 0000000000000026 _start
让我们开始简化程序。当我们剥离复杂性时,我们将有机会一次理解一些东西。
没有 libc 的生活
我们程序中的一个主要复杂性来源是标准库。它们用于打印字符串和初始化程序。让我们摆脱它们。
很简单,只需使用:-nostdlib
编译。
不幸的是,这意味着我们不再能访问 printf
(或 puts
)函数。这很不幸,因为我们仍然想打印“Hello Simplicity!”。
这也意味着我们将失去 _start
函数。它由 C 运行时库(CRT)提供,用于执行一些初始化(如清除 .bss
段)并调用我们的 main
函数。由于我们仍然需要执行我们的 main
,我们必须对此做些处理。
幸运的是,我们可以使用 -Wl,-e,<function_name>
提供我们自己的入口点。我们可以直接指定 main
作为我们的入口点,但这意味着将其视为 void main()
而不是 int main()
。入口点不返回任何东西。我觉得改变 main
的签名有点过分了;让我们创建自己的 void startup()
函数来调用 main
。
为了写入 stdout
,我们使用 syscall
汇编指令。这条指令是我们请求 Linux 内核为我们做事的方式。在这种情况下,我们希望执行 write
系统调用将字符串写入 stdout
(文件描述符 = 1)。稍后,我们还希望调用 exit
以终止进程。
调用 syscall
指令时,我们在 rax
寄存器中传递系统调用号,在 rdi
、rsi
和 rdx
寄存器中传递参数。write
系统调用的编号是 0x01
,exit
系统调用的编号是 0x3c
。
它们的 C 签名如下:
ssize_t write(int fildes, const void *buf, size_t nbyte);
void exit(int status);
这是我们的新程序 hello-syscall.c
:
int main() {
volatile const char message[] = "Hello Simplicity!\n";
volatile const unsigned long length = sizeof(message) - 1;
// write(1, message, length)
asm volatile("mov $1, %%rax\n" // write syscall number (0x01)
"mov $1, %%rdi\n" // Stdout file descriptor (0x01)
"mov %0, %%rsi\n" // Message buffer
"mov %1, %%rdx\n" // Buffer length
"syscall" // Make the syscall
: // No output operands
: "r"(message), "r"(length) // Input operands
: "%rax", "%rdi", "%rsi", "%rdx" // Clobbered registers
);
return 0;
}
void startup() {
volatile unsigned long status = main();
// exit(status)
asm volatile("mov $0x3c, %%rax\n" // exit syscall number (0x3c)
"mov %0, %%rdi\n" // exit status
"syscall" // Make the syscall
: // No output operands
: "r"(status) // Input operands
: "%rax", "%rdi" // Clobbered registers
);
}
如果你在想,volatile
关键字是为了防止 GCC 优化掉变量。而 unsigned long
被用来代替 int
是为了匹配 r__
64 位寄存器的大小。
我们这样构建它:
gcc -Wl,-entry=startup -nostdlib -o hello-nostd hello-syscall.c
这真的比之前更简单吗?嗯,是的!
除非你习惯了汇编语言、系统调用和自定义入口点,否则它可能不会更容易理解。但简单并不等同于容易。简单是复杂的反义词。复杂的东西本质上很难理解,无论你知道多少。简单的东西只有在你掌握了适当的技能后才会变得容易理解。Rich Hickey 在他 2011 年的演讲 "Simple Made Easy" 中对此进行了精彩的解释。
仍然不相信我们真的让程序变得更简单了吗?让我们看看符号和段:
$ objdump -h -t hello-nostd
Sections:
Idx Name Size VMA LMA File off Algn
0 .interp 0000001c 0000000000000318 0000000000000318 00000318 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
1 .note.gnu.property 00000020 0000000000000338 0000000000000338 00000338 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
2 .note.gnu.build-id 00000024 0000000000000358 0000000000000358 00000358 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
3 .gnu.hash 0000001c 0000000000000380 0000000000000380 00000380 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
4 .dynsym 00000018 00000000000003a0 00000000000003a0 000003a0 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
5 .dynstr 00000001 00000000000003b8 00000000000003b8 000003b8 2**0
CONTENTS, ALLOC, LOAD, READONLY, DATA
6 .text 0000007f 0000000000001000 0000000000001000 00001000 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
7 .eh_frame_hdr 0000001c 0000000000002000 0000000000002000 00002000 2**2
CONTENTS, ALLOC, LOAD, READONLY, DATA
8 .eh_frame 00000058 0000000000002020 0000000000002020 00002020 2**3
CONTENTS, ALLOC, LOAD, READONLY, DATA
9 .dynamic 000000e0 0000000000003f20 0000000000003f20 00002f20 2**3
CONTENTS, ALLOC, LOAD, DATA
10 .comment 0000002b 0000000000000000 0000000000000000 00003000 2**0
CONTENTS, READONLY
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 hello-syscall.c
0000000000000000 l df *ABS* 0000000000000000
0000000000003f20 l O .dynamic 0000000000000000 _DYNAMIC
0000000000002000 l .eh_frame_hdr 0000000000000000 __GNU_EH_FRAME_HDR
0000000000001050 g F .text 000000000000002f startup
0000000000004000 g .dynamic 0000000000000000 __bss_start
0000000000001000 g F .text 0000000000000050 main
0000000000004000 g .dynamic 0000000000000000 _edata
0000000000004000 g .dynamic 0000000000000000 _end
这里仍然有很多内容,但至少现在它可以在一个屏幕上显示。正如预期的那样,objdump -f
给了我们一个新的起始地址:0x1050
。这是我们的 startup
函数!
让我们继续简化!
没有 PIE 的生活
在过去的 20 年里,你的程序被加载到随机地址作为一种安全缓解措施。ASLR(地址空间布局随机化)使得编写漏洞利用变得更加困难,因为 shellcode 不能跳转到硬编码的目标。这也意味着你常规程序中的跳转不能硬编码。
默认情况下,现代系统上的程序被构建为位置无关可执行文件(PIE)。地址在程序加载到内存时解析。这对安全性很有好处,但增加了复杂性。让我们用 -no-pie
去掉它。
为了进一步简化我们的汇编代码,我们用 -fcf-protection=none
和 -fno-stack-protector
关闭一些安全功能。我们还用 -Wl,--build-id=none
去掉一些元数据生成,并用 -fno-unwind-tables
和 -fno-asynchronous-unwind-tables
去掉一些调试器友好的堆栈展开信息。
gcc -no-pie \
-nostdlib \
-Wl,-e,startup \
-Wl,--build-id=none \
-fcf-protection=none \
-fno-stack-protector \
-fno-asynchronous-unwind-tables \
-fno-unwind-tables \
-o hello-nostd-nopie hello.c
我们现在得到了这个:
$ objdump -h -t hello-nostd-nopie
hello-nostd-nopie: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000077 0000000000401000 0000000000401000 00001000 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 .comment 0000002b 0000000000000000 0000000000000000 00001077 2**0
CONTENTS, READONLY
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 hello-syscall.c
000000000040104c g F .text 000000000000002b startup
0000000000402000 g .text 0000000000000000 __bss_start
0000000000401000 g F .text 000000000000004c main
0000000000402000 g .text 0000000000000000 _edata
0000000000402000 g .text 0000000000000000 _end
你注意到使用 -no-pie
后符号地址是如何变化的吗?之前,它们是相对的,等待在加载时添加某个偏移量。现在,它们是绝对的,main
将真正位于 0x00401000
。
$ gdb hi
(gdb) break main
Breakpoint 1 at 0x401004
(gdb) run
Breakpoint 1, 0x0000000000401004 in main ()
呼!我们终于接近一些简单的东西了。现在,我们的整个程序甚至可以在一个屏幕上显示:
$ objdump -d -M intel hello-nostd-nopie
Disassembly of section .text:
0000000000401000 <main>:
401000: 55 push rbp
401001: 48 89 e5 mov rbp,rsp
401004: 48 b8 48 65 6c 6c 6f movabs rax,0x6953206f6c6c6548
40100b: 20 53 69
40100e: 48 ba 6d 70 6c 69 63 movabs rdx,0x79746963696c706d
401015: 69 74 79
401018: 48 89 45 e0 mov QWORD PTR [rbp-0x20],rax
40101c: 48 89 55 e8 mov QWORD PTR [rbp-0x18],rdx
401020: 66 c7 45 f0 21 0a mov WORD PTR [rbp-0x10],0xa21
401026: c6 45 f2 00 mov BYTE PTR [rbp-0xe],0x0
40102a: 48 c7 45 d8 12 00 00 mov QWORD PTR [rbp-0x28],0x12
401031: 00
401032: 4c 8b 45 d8 mov r8,QWORD PTR [rbp-0x28]
401036: 48 8d 4d e0 lea rcx,[rbp-0x20]
40103a: 48 c7 c0 01 00 00 00 mov rax,0x1
401041: 48 c7 c7 01 00 00 00 mov rdi,0x1
401048: 48 89 ce mov rsi,rcx
40104b: 4c 89 c2 mov rdx,r8
40104e: 0f 05 syscall
401050: b8 00 00 00 00 mov eax,0x0
401055: 5d pop rbp
401056: c3 ret
0000000000401057 <startup>:
401057: 55 push rbp
401058: 48 89 e5 mov rbp,rsp
40105b: 48 83 ec 10 sub rsp,0x10
40105f: b8 00 00 00 00 mov eax,0x0
401064: e8 97 ff ff ff call 401000 <main>
401069: 48 98 cdqe
40106b: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
40106f: 48 8b 55 f8 mov rdx,QWORD PTR [rbp-0x8]
401073: 48 c7 c0 3c 00 00 00 mov rax,0x3c
40107a: 48 89 d7 mov rdi,rdx
40107d: 0f 05 syscall
40107f: 90 nop
401080: c9 leave
401081: c3 ret
你可以看到 startup
函数调用 main
,两个系统调用,以及硬编码为一大串 ASCII 值的 "Hello Simplicity!" 字符串(相对于栈基指针 rbp
被加载到栈上)。
至少在这个层面上,已经没有太多复杂性了。我们的 ELF 实际上相当简单!但等等,还有更多!
链接脚本
那些奇怪的符号(比如 __bss_start
)从哪里来的?是谁决定我们的 startup
函数应该加载到内存中的 0x0040104c
?如果我们想让我们的代码位于酷炫的 0xc0d30000
地址范围内怎么办?
这些东西在链接脚本中指定。到目前为止,我们一直在使用默认的链接脚本,你可以通过 ld -verbose
查看它。它非常复杂。让我们摆脱它。
我们的简单 hello world 应用程序不使用任何全局变量。如果有,它们将分为三类:
.rodata
:编译时提供值的常量,比如我们的硬编码字符串。.data
:编译时提供值的非 const 变量。.bss
:未初始化的全局变量。
让我们通过引入每个类别的符号来稍微复杂化我们的程序。这将提供一个更有趣的链接脚本示例。这里是新程序 hello-data.c
:
const char message[] = "Hello Simplicity!\n"; // .rodata
unsigned long length = sizeof(message) - 1; // .data
unsigned long status; // .bss
int main() {
// write(1, message, length)
asm volatile("mov $1, %%rax\n" // write syscall number (0x01)
"mov $1, %%rdi\n" // Stdout file descriptor (0x01)
"mov %0, %%rsi\n" // Message buffer
"mov %1, %%rdx\n" // Buffer length
"syscall" // Make the syscall
: // No output operands
: "r"(message), "r"(length) // Input operands
: "%rax", "%rdi", "%rsi", "%rdx" // Clobbered registers
);
return 0;
}
void startup() {
status = main();
// exit(status)
asm volatile("mov $0x3c, %%rax\n" // exit syscall number (0x3c)
"mov %0, %%rdi\n" // exit status
"syscall" // Make the syscall
: // No output operands
: "r"(status) // Input operands
: "%rax", "%rdi" // Clobbered registers
);
}
再次查看符号表,不使用自定义链接脚本,我们可以看到 .data
、.rodata
和 .bss
中的全局变量分别如下:
000000000040102f g F .text 000000000000002d startup
0000000000403010 g O .data 0000000000000008 length
0000000000402000 g O .rodata 000000000000000e message
0000000000401000 g F .text 000000000000002f main
0000000000403018 g O .bss 0000000000000008 status
现在,让我们创建一个简单有趣的链接脚本 (hello.ld
),带有酷炫的内存映射和表情符号作为段名:
MEMORY {
IRAM (rx) : ORIGIN = 0xC0DE0000, LENGTH = 0x1000
RAM (rw) : ORIGIN = 0xFEED0000, LENGTH = 0x1000
ROM (r) : ORIGIN = 0xDEAD0000, LENGTH = 0x1000
}
SECTIONS
{
"📜 .text" : {
*(.text*)
} > IRAM
"📦 .data" : {
*(.data*)
} > RAM
"📁 .bss" : {
*(.bss*)
} > RAM
"🧊 .rodata" : {
*(.rodata*)
} > ROM
/DISCARD/ : { *(.comment) }
}
ENTRY(startup)
我们使用与之前相同的构建选项,但添加 -T hello.ld
以开始使用我们的链接脚本。
这是最终形式的简单程序:
$ objdump -t -h hello-data
hello-data: file format elf64-x86-64
Sections:
Idx Name Size VMA LMA File off Algn
0 📜 .text 0000005c 00000000c0de0000 00000000c0de0000 00001000 2**0
CONTENTS, ALLOC, LOAD, READONLY, CODE
1 📦 .data 00000008 00000000feed0000 00000000feed0000 00003000 2**3
CONTENTS, ALLOC, LOAD, DATA
2 📁 .bss 00000008 00000000feed0008 00000000feed0008 00003008 2**3
ALLOC
3 🧊 .rodata 00000013 00000000dead0000 00000000dead0000 00002000 2**4
CONTENTS, ALLOC, LOAD, READONLY, DATA
SYMBOL TABLE:
0000000000000000 l df *ABS* 0000000000000000 hello-data.c
00000000c0de002f g F 📜 .text 000000000000002d startup
00000000feed0000 g O 📦 .data 0000000000000008 length
00000000dead0000 g O 🧊 .rodata 0000000000000013 message
00000000c0de0000 g F 📜 .text 000000000000002f main
00000000feed0008 g O 📁 .bss 0000000000000008 status
是不是非常可爱?
我在 github.com/4ZM/elf-shenanigans 上放了一些示例代码,可以重现本文中的示例。
如果你想了解更多关于链接脚本的内容(为什么不呢?!),这是一个出色的技术文档:c_Using_LD。
如果你想探索更多关于段名的荒谬事情,请查看我的另一篇文章:ELF Shenanigans。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY
· 【自荐】一款简洁、开源的在线白板工具 Drawnix