大数跨境
0
0

x64汇编与shellcode入门教程 01

x64汇编与shellcode入门教程 01 CppGuide
2025-07-15
0

写在前面的话

我最近在研究一些安全软件的源码,涉及到一些汇编与shellcode代码,有些知识点有点生疏了,特准备此系列专题文章,一方面帮助自己重拾汇编与shellcode相关知识点,另一方面希望对感兴趣的小伙伴有所帮助。

必须承认,我在互联网上四处搜寻x64基础 shellcode 开发的示例,但收获不大。许多教程和课程似乎仍侧重于x86汇编,甚至许多现代的shellcode课程也仍以x86为主。别误会,x86很棒,学习曲线也没那么陡峭。但在你的渗透测试之旅中,大多数有效载荷将基于x64架构,这是有区别的!我希望提供一系列循序渐进的课程,帮助读者你获得必要的资源和知识,以便顺利学习x64汇编/shellcode开发,尽量少走弯路。那么,我们开始吧,好吗?

声明 - 说到x64汇编语言,我可不是专家。但我所掌握的知识,至少足以指导那些有兴趣的人学习基础知识,并生成可用于漏洞利用开发、逆向工程概念和渗透测试项目的可用shellcode。

最后,NASM(The Netwide Assembler)汇编语法将作为我们x64汇编编码需求的首选语法。让我们开始吧!🐱

第1部分 - x64 基础知识:寄存器

好的,让我们先把那些枯燥但至关重要的信息讲清楚。在x64汇编中,有两种类型的寄存器值:

  • 易失性(Volatile)寄存器:适用于寄存器RAX、RCX、RDX、R8、R9、R10、R11
  • 非易失性(Non-Volatile)寄存器:RBX、RBP、RDI、RSI、R12、R13、R14、R15、RSP

Volatile寄存器正如其名,会根据函数调用等情况改变值。

Non-Volatile寄存器在函数调用后不会改变值,并且可以可靠地用于存储你的代码中需要的数值。

寄存器 RCXRDXR8 和 R9 按此确切顺序用作参数。例如,当你执行 ExitProcess 并将第一个参数 0 传递给函数调用时,你会使用寄存器 RCX,如下所示:

; --- GetProcess ---
mov r15, rax ;address for GetProcess previously acquired
mov rcx, 0   ;move '0' into the first and only expected parameter
call r15     ;Execute GetProcess!!!

如果有多个参数怎么办呢?嗯,那会将RCX用作第一个参数,RDX用作第二个参数。如果你还有第三个和第四个参数值,那么将分别使用r8和r9。以下是WinExec的x64汇编代码,将应用程序字符串传入RCX,将值“1”传入RDX。如果应用程序有窗口/图形用户界面要显示,1就相当于“显示窗口”。

; --- WinExec ---
pop r15                         ;address for WinExec previously acquired
mov rax, 0x00                   ;NULL byte
push rax                        ;push to stack
mov rax, 0x6578652E636C6163     ;calc.exe 
push rax                        ;push to stack
mov rcx, rsp                   ; RCX, our first parameter, now points to the string of the application we wish to execute: "calc.exe"
mov rdx, 1                      ; move 1 into RDX as the 2nd parameter to display the application's GUI/window
sub rsp, 0x30                   ; I'
ll explain this in greater detail later.  It involves shadow space/16 byte stack alignment
call r15                        ; Execute WinExec!!!

四个参数都用上怎么样?我们可以用MessageBoxA来演示一下:

mov r15, rax                   ; MessageBoxA address previously acquired
mov rcx, 0                     ; 1st Parameter - hWnd = NULL (no owner window)
mov rax, 0x006D                ; move the final letter, m, into RAX and null terminate with a '0'
push rax                       ; push 'm' and 0 to the stack, pointed to by RAX
mov rax, 0x3374737973743367    ; move the first 8 characters of the string 'g3tsyst3' into RAX.  
push rax                       ; push 'g3tsyst3' string to the stack, pointed to by RAX
mov rdx, rsp                   ; 2nd Parameter - lpText = pointer to message
    
mov r8, rsp                    ; 3rd Parameter - lpCaption = pointer to title
mov r9d, 0                     ; 4th Parameter - uType = MB_OK (OK button only)

sub rsp, 0x30                  ;I'll explain this in greater detail later.  It involves shadow space/16 byte stack alignment
call r15                       ; Call MessageBoxA

请注意我是如何使用寄存器 R15 来存储API的地址值的。我选择这个寄存器是因为和它的其他同类寄存器R14、R13和R12一样,它是非易失性的,这意味着在函数调用后它不会被改变。当你需要保留一个尚未压入堆栈的值时,这些非易失性寄存器至关重要。这里有一个函数调用前后寄存器值的示例。请注意,所有易失性寄存器的值都如预期般发生了变化,但R15保持不变。

调用前:


函数调用结束后:


好的!以上就是对x64寄存器的大致解析。接下来继续!

第1部分 - x64基础知识:栈对齐

我保证,枯燥的内容就快讲完了。有趣的部分马上就来。😺 好啦,继续往下讲。咱们来聊聊 16 byte stack alignment convention。如果你觉得这听起来像天书,别担心,虽然实现起来有点繁琐,但相当简单明了。我会尽可能简单地讲解。

在x64汇编中,栈以16字节边界运行。在进行函数调用之前,栈需要根据这一原则进行对齐。

简单来说,在进行函数调用之前,RSP必须能被16整除。

不必只关注16字节对齐时RSP的具体值,你可以将此要求理解为栈指针(RSP)需要位于任何能被16整除的地址上(即0x10、0x20、0x30等)。这意味着,任何使得RSP % 16 == 0的 RSP 值都被视为是对齐的。

整除示例:

PUSHCALL是会使栈指针递减8字节的指令示例。POP会使栈指针递增8字节。这将改变栈对齐方式。例如:


执行POP指令之前,RSP的十位数值是0x88,即十进制的136。这个数不能被16整除(136/16 = 8.5)。 然而…

在执行POP指令后(该指令会使RSP增加8字节),栈又恢复到了16字节对齐的状态。

现在,RSP的十位保存着十六进制值0x90,十进制为144,且能被16整除(144/16 = 9)!仔细想想,这本质上非常数学化。不管你喜欢还是讨厌,这都是x64汇编的一部分,但它并不像看上去那么痛苦。在整个代码过程中,最好让堆栈保持对齐状态,不过在函数调用之前保持对齐尤为重要。如果堆栈没有正确对齐,你的代码很可能会跳转到内存中意想不到的位置并导致失败。

第1部分 - x64 基础:影子空间(Shadow Space)

好的,希望你还能坚持读到这里,而且目前为止一切都能理解。如果还有什么不太明白的地方,可以给我发私信。好了,我保证我们马上就要讲完这篇文章x64基础部分啦!🐶 现在我们来谈谈Shadow Space,也叫home space / 或叫spill space

**在Windows x64调用约定中,调用方需要为被调用方预留32字节(4个8字节的槽位)作为shadow space**,即使函数并不需要它。这个空间是预留的,但不会自动调整,除非使用诸如sub rsp, 0x20或其他指令显式处理。

函数经常需要额外的栈空间来存放局部变量并进行进一步对齐。你可能会看到sub rsp, 0x30,甚至像sub rsp, 0x40这样更大的调整,以便在函数调用前分配影子空间和额外空间。我在自己的代码中经常这样做。再次强调,这有助于确保当函数需要将预期值以及可能的非预期值压入栈中时,有足够的空间,并且有助于确保RSP始终保持16字节对齐。这里有一个示意图,能帮助你更好地理解这一点。

首先,我将注释掉函数调用前的影子空间分配,看看会发生什么:

GetProcAddress(hKernel32, "LoadLibraryA");


然后编译它(我喜欢用ld.exe来编译我的x64汇编代码):

>nasm -fwin64 getproc.asm
>ld -m i386pep -o getproc.exe getproc.obj

RCX存放着kernel32的基地址,RDX存放着指向我们的"LoadLibraryA"字符串的指针,而 R15存放着GetProcAddress的地址:


如果我们在函数调用前完全忽略设置任何影子空间,似乎通常会放入RAX的返回值就无法正常工作。如果函数调用后RAX为0,这通常不是好事。我们放在栈上的参数和其他数据,在没有我们应该提供给函数的正常预留空间的情况下,很可能被破坏了。看看这个:

函数调用前:


函数调用后:


好吧,这证明了如果不设置适当的影子空间储备,事情会变得多么糟糕。我们现在就这么做,看看情况会不会对我们更有利:😸

现在,我们将添加影子空间,在同一位置重新编译和反汇编程序,看看会发生什么:


中啦! 就在这儿,如我们所愿找到了LoadLibraryA这个Windows API的地址。 就在那里,在RAX寄存器中热切地等待着我们。 你还会看到我们对影子空间堆栈的调整:

我可以不断地讲述减轻潜在影子空间问题的方法。但这会让你很好地了解预期情况,以及如何为使用x64 16字节堆栈对齐和影子空间要求的函数调用做准备。如果你想了解更多关于这个主题的信息,一如既往地在X上联系我,我可以更详细地讨论这个问题。既然我们已经对x64汇编的寄存器和堆栈对齐要求有了一个很好的概述,那么让我们深入到这篇文章的下一部分。

第2部分 - x64首个程序:动态定位WinExec并执行calc.exe

在处理完所有必要的乏味事务后,我们终于要开始做令人兴奋的事了。

好的,我将基于这样一个假设,即你已经熟悉了一些常规的x64指令。如果还不熟悉,也不用担心!我会添加注释,以帮助解释你应该熟悉的最常见指令,并帮助你理解它们的工作原理。此外,我还假设你知道定位kernel32基地址以及遍历PE文件(可执行文件)的导出表以查找函数/API名称的序号的基本模板是什么。如果有机会,我建议你熟悉一下PE导出表,但目前你可以直接在我的模板基础上进行操作。

让我们从定位kernel32基地址开始。这实际上非常简单!

;nasm -fwin64 [x64findkernel32.asm]
;ld -m i386pep -o x64findkernel32.exe x64findkernel32.obj

BITS 64
SECTION .text
global main
main:

sub rsp, 0x28
and rsp, 0xFFFFFFFFFFFFFFF0
xor rcx, rcx             ;RCX = 0
mov rax, [gs:rcx + 0x60] ;RAX = PEB
mov rax, [rax + 0x18]    ;RAX = PEB / Ldr
mov rsi,[rax+0x10]       ;PEB_Ldr / InLoadOrderModuleList
mov rsi, [rsi]           ;could substitute lodsq here instead if you like
mov rsi,[rsi]            ;also could substitute lodsq here too
mov rbx, [rsi+0x30]      ;kernel32.dll base address
mov r8, rbx              ;mov kernel32.dll base addr into register of your choosing

好的,kernel32 base address 现在存于 r8 中。r8 是一个 易失性 寄存器,所以如果你需要多次使用这个寄存器,一定要将该寄存器保存的值转移到另一个寄存器中,因为在你第一次调用函数后,该值几乎肯定会被覆盖。我们来测试一下,看看是否能获取到kernel32基地址。果然,它就在 RBX 中,并且也在我们复制到的 R8 中:

既然我们已经获取了kernel32的基地址,那我们接着获取函数总数以及相对虚拟地址(RVA)/虚拟内存地址(VMA)的信息:

;Code for parsing Export Address Table
mov ebx, [rbx+0x3C]           ; Get Kernel32 PE Signature (0x3C) into EBX
add rbx, r8                   ; signature offset
mov edx, [rbx+0x88]           ; PE32 Signature / Export Address Table
add rdx, r8                   ; kernel32.dll + RVA ExportTable = ExportTable Address
mov r10d, [rdx+0x14]          ; Total count for number of functions
xor r11, r11                  ; clear R11 
mov r11d, [rdx+0x20]          ; AddressOfNames = RVA
add r11, r8                   ; AddressOfNames = VMA

接下来,让我们插入我们要查找的函数名,并设置函数计数器:

mov rcx, r10                  ; Setup loop counter

mov rax, 0x00636578456E6957   ;"WinExec" string NULL terminated with a '0' 
push rax                      ;push to the stack
mov rax, rsp                 ;move stack pointer to our WinExec string into RAX
add rsp, 8                    ;keep with 16 byte stack alignment

现在,让我们找出 WinExec 函数:

; Loop over Export Address Table to find WinApi names
kernel32findfunction: 
    jecxz FunctionNameNotFound    ; If ecx is zero (function not found), set breakpoint
    xor ebx,ebx                   ; Zero EBX
    mov ebx, [r11+rcx*4]          ; EBX = RVA for first AddressOfName
    add rbx, r8                   ; RBX = Function name VMA / add kernel32 base address to RVA to get WinApi name
    dec rcx                       ; Decrement our loop by one, this goes from Z to A
   
    mov r9, qword [rax]                ; R9 = "WinExec"
    cmp [rbx], r9                      ; Compare all bytes
    jz FunctionNameFound               ; jump if zero flag is set (found function name!)
 jnz kernel32findfunction             ; didn't find the name, so keep loopin til we do!

FunctionNameFound:
push rcx                               ; found it, so save it for later
jmp OrdinalLookupSetup

FunctionNameNotFound:
int3

现在是代码的最后一部分:

OrdinalLookupSetup:  ;We found our target WinApi position in the functions lookup
   pop r15         ;getprocaddress position
   js OrdinalLookup
   
OrdinalLookup:   
   mov rcx, r15                  ; move our function's place into RCX
   xor r11, r11                  ; clear R11 for use
   mov r11d, [rdx+0x24]          ; AddressOfNameOrdinals = RVA
   add r11, r8                   ; AddressOfNameOrdinals = VMA
   ; Get the function ordinal from AddressOfNameOrdinals
   inc rcx
   mov r13w, [r11+rcx*2]         ; AddressOfNameOrdinals + Counter. RCX = counter
   ;With the function ordinal value, we can finally lookup the WinExec address from AddressOfFunctions.

   xor r11, r11
   mov r11d, [rdx+0x1c]          ; AddressOfFunctions = RVA
   add r11, r8                   ; AddressOfFunctions VMA in R11. Kernel32+RVA for function addresses
   mov eax, [r11+r13*4]          ; function RVA.
   add rax, r8                   ; Found the WinExec Api address!!!
   push rax                      ; Store function addresses by pushing it temporarily
   js executeit

看看我们的WinExec API地址现在是否在RAX中:

果然,它在那里!


现在,让我们使用新找到的WinExec地址来执行calc.exe!

executeit:
; --- prepare to call WinExec ---
pop r15                         ;address for WinExec
mov rax, 0x00                   ;push null string terminator '0'
push rax                        ;push it onto the stack
mov rax, 0x6578652E636C6163     ; move string 'calc.exe' into RAX 
push rax                        ; push string + null terminator to stack
mov rcx, rsp                 ; RDX points to stack pointer "WinExec" (1st parameter))
mov rdx, 1                      ; move 1 (show window parameter) into RDX (2nd parameter)
sub rsp, 0x30                   ; align stack 16 bytes and allow for proper setup for shadow space demands
call r15                        ; Call WinExec!!

我这里就不给计算器程序拍照啦。相信我,它加载出来了😸 然而!!! 这个编译后的程序不能正常退出,因为我们没有加载 ExitProcess。这可以作为你的作业。试着利用这篇文章中获取的信息,找到定位ExitProcess(它也在kernel32.dll中)的方法,并干净利落地退出这个程序。好了,进入我们的最后一部分……

第3部分 - 转换为x64 shellcode:执行自定义shellcode

首先,继续编译它:

nasm.exe -f win64 winexec.asm -o winexec.o

这将生成一个.obj文件。现在,只需执行以下操作:

objdump -d winexec.o

你应该获取你的shellcode输出以及汇编指令。这是我的输出示例。

Disassembly of section .text:

0000000000000000 <main>:
   0:   48 83 ec 28             sub    $0x28,%rsp
   4:   48 83 e4 f0             and    $0xfffffffffffffff0,%rsp
   8:   48 31 c9                xor    %rcx,%rcx
   b:   65 48 8b 41 60          mov    %gs:0x60(%rcx),%rax
  10:   48 8b 40 18             mov    0x18(%rax),%rax
  14:   48 8b 70 10             mov    0x10(%rax),%rsi
  18:   48 8b 36                mov    (%rsi),%rsi
  1b:   48 8b 36                mov    (%rsi),%rsi
  1e:   48 8b 5e 30             mov    0x30(%rsi),%rbx
  22:   49 89 d8                mov    %rbx,%r8
  25:   8b 5b 3c                mov    0x3c(%rbx),%ebx
  28:   4c 01 c3                add    %r8,%rbx
  2b:   8b 93 88 00 00 00       mov    0x88(%rbx),%edx
  31:   4c 01 c2                add    %r8,%rdx
  34:   44 8b 52 14             mov    0x14(%rdx),%r10d
  38:   4d 31 db                xor    %r11,%r11
  3b:   44 8b 5a 20             mov    0x20(%rdx),%r11d
  3f:   4d 01 c3                add    %r8,%r11
  42:   4c 89 d1                mov    %r10,%rcx
  45:   48 b8 57 69 6e 45 78    movabs $0x636578456e6957,%rax
  4c:   65 63 00
  4f:   50                      push   %rax
  50:   48 89 e0                mov    %rsp,%rax
  53:   48 83 c4 08             add    $0x8,%rsp
  57:   eb 00                   jmp    59 <kernel32findfunction>

0000000000000059 <kernel32findfunction>:
  59:   67 e3 19                jecxz  75 <FunctionNameNotFound>
  5c:   31 db                   xor    %ebx,%ebx
  5e:   41 8b 1c 8b             mov    (%r11,%rcx,4),%ebx
  62:   4c 01 c3                add    %r8,%rbx
  65:   48 ff c9                dec    %rcx
  68:   4c 8b 08                mov    (%rax),%r9
  6b:   4c 39 0b                cmp    %r9,(%rbx)
  6e:   74 02                   je     72 <FunctionNameFound>
  70:   75 e7                   jne    59 <kernel32findfunction>

0000000000000072 <FunctionNameFound>:
  72:   51                      push   %rcx
  73:   eb 01                   jmp    76 <OrdinalLookupSetup>

0000000000000075 <FunctionNameNotFound>:
  75:   cc                      int3

0000000000000076 <OrdinalLookupSetup>:
  76:   41 5f                   pop    %r15
  78:   78 00                   js     7a <OrdinalLookup>

000000000000007a <OrdinalLookup>:
  7a:   4c 89 f9                mov    %r15,%rcx
  7d:   4d 31 db                xor    %r11,%r11
  80:   44 8b 5a 24             mov    0x24(%rdx),%r11d
  84:   4d 01 c3                add    %r8,%r11
  87:   48 ff c1                inc    %rcx
  8a:   66 45 8b 2c 4b          mov    (%r11,%rcx,2),%r13w
  8f:   4d 31 db                xor    %r11,%r11
  92:   44 8b 5a 1c             mov    0x1c(%rdx),%r11d
  96:   4d 01 c3                add    %r8,%r11
  99:   43 8b 04 ab             mov    (%r11,%r13,4),%eax
  9d:   4c 01 c0                add    %r8,%rax
  a0:   50                      push   %rax
  a1:   78 00                   js     a3 <executeit>

00000000000000a3 <executeit>:
  a3:   41 5f                   pop    %r15
  a5:   b8 00 00 00 00          mov    $0x0,%eax
  aa:   50                      push   %rax
  ab:   48 b8 63 61 6c 63 2e    movabs $0x6578652e636c6163,%rax
  b2:   65 78 65
  b5:   50                      push   %rax
  b6:   48 89 e1                mov    %rsp,%rcx
  b9:   ba 01 00 00 00          mov    $0x1,%edx
  be:   48 83 ec 30             sub    $0x30,%rsp
  c2:   41 ff d7                call   *%r15

现在让我们提取 shellcode:

for i in $(objdump -D winexec.o | grep “^ “ | cut -f2); do echo -n “\x$i” ; done

只提取机器码后的样子如下:

“\x48\x83\xec\x28\x48\x83\xe4\xf0\x48\x31\xc9\x65\x48\x8b\x41\x60\x48\x8b” “\x40\x18\x48\x8b\x70\x10\x48\x8b\x36\x48\x8b\x36\x48\x8b\x5e\x30\x49\x89” “\xd8\x8b\x5b\x3c\x4c\x01\xc3\x8b\x93\x88\x00\x00\x00\x4c\x01\xc2\x44\x8b” “\x52\x14\x4d\x31\xdb\x44\x8b\x5a\x20\x4d\x01\xc3\x4c\x89\xd1\x48\xb8\x57” “\x69\x6e\x45\x78\x65\x63\x00\x50\x48\x89\xe0\x48\x83\xc4\x08\xeb\x00\x67” “\xe3\x19\x31\xdb\x41\x8b\x1c\x8b\x4c\x01\xc3\x48\xff\xc9\x4c\x8b\x08\x4c” “\x39\x0b\x74\x02\x75\xe7\x51\xeb\x01\xcc\x41\x5f\x78\x00\x4c\x89\xf9\x4d” “\x31\xdb\x44\x8b\x5a\x24\x4d\x01\xc3\x48\xff\xc1\x66\x45\x8b\x2c\x4b\x4d” “\x31\xdb\x44\x8b\x5a\x1c\x4d\x01\xc3\x43\x8b\x04\xab\x4c\x01\xc0\x50\x78” “\x00\x41\x5f\xb8\x00\x00\x00\x00\x50\x48\xb8\x63\x61\x6c\x63\x2e\x65\x78” “\x65\x50\x48\x89\xe1\xba\x01\x00\x00\x00\x48\x83\xec\x30\x41\xff\xd7”;

现在,这一切的最后一步。让我们将x64 shellcode添加到一个自定义的C++程序中并执行它!

#include <windows.h>
#include <iostream>

unsigned char shellcode[] =
"\x48\x83\xec\x28\x48\x83\xe4\xf0\x48\x31\xc9\x65\x48\x8b\x41\x60"
"\x48\x8b\x40\x18\x48\x8b\x70\x10\x48\x8b\x36\x48\x8b\x36\x48\x8b"
"\x5e\x30\x49\x89\xd8\x8b\x5b\x3c\x4c\x01\xc3\x8b\x93\x88\x00\x00"
"\x00\x4c\x01\xc2\x44\x8b\x52\x14\x4d\x31\xdb\x44\x8b\x5a\x20\x4d"
"\x01\xc3\x4c\x89\xd1\x48\xb8\x57\x69\x6e\x45\x78\x65\x63\x00\x50"
"\x48\x89\xe0\x48\x83\xc4\x08\xeb\x00\x67\xe3\x19\x31\xdb\x41\x8b"
"\x1c\x8b\x4c\x01\xc3\x48\xff\xc9\x4c\x8b\x08\x4c\x39\x0b\x74\x02"
"\x75\xe7\x51\xeb\x01\xcc\x41\x5f\x78\x00\x4c\x89\xf9\x4d\x31\xdb"
"\x44\x8b\x5a\x24\x4d\x01\xc3\x48\xff\xc1\x66\x45\x8b\x2c\x4b\x4d"
"\x31\xdb\x44\x8b\x5a\x1c\x4d\x01\xc3\x43\x8b\x04\xab\x4c\x01\xc0"
"\x50\x78\x00\x41\x5f\xb8\x00\x00\x00\x00\x50\x48\xb8\x63\x61\x6c"
"\x63\x2e\x65\x78\x65\x50\x48\x89\xe1\xba\x01\x00\x00\x00\x48\x83"
"\xec\x30\x41\xff\xd7";

int main() {
    // 注意标志位PAGE_EXECUTE_READWRITE,给这段空间的数据设置为具有读写和可执行权限
    void* exec_mem = VirtualAlloc(0, sizeof(shellcode), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);

    if (exec_mem == nullptr) {
        std::cerr << "Memory allocation failed\n";
        return -1;
    }
    memcpy(exec_mem, shellcode, sizeof(shellcode));
    auto shellcode_func = reinterpret_cast<void(*)()>(exec_mem);
    shellcode_func();
    VirtualFree(exec_mem, 0, MEM_RELEASE);
    return 0;
}

信不信由你,我们才刚刚热身!我希望你和我一样兴奋,因为下一部分将介绍如何去除空字节(NULL bytes),这样我们就能在缓冲区溢出漏洞利用中使用这段 shellcode 啦!😸 我也希望这部分内容能让你有所收获,并且还算容易理解。我花了不少时间才把所有信息整合起来感谢大家!下一篇文章我们将专注于去除空字节 “00”,并学习如何使用 “GetProcAddress” 动态定位函数,然后弹出一个消息框。到时候见!


   关注我,更多实用的开发技术第一时间阅读


推荐阅读



    【声明】内容源于网络
    0
    0
    CppGuide
    专注于高质量高性能C++开发,站点:cppguide.cn
    内容 981
    粉丝 0
    CppGuide 专注于高质量高性能C++开发,站点:cppguide.cn
    总阅读2
    粉丝0
    内容981