Dll注入过滤任意Windows控制台命令行输入

命令提示符也就是命令行控制台,新版本也叫做Windows 终端。如何做到当命令被输入控制台窗口后能够做到过滤呢?

其中,有一种就是键盘钩子判断键盘输入,但实用性可能不高。

另外一种方法就是获取控制台缓冲区的文本,了解用户接下来要使用那种指令,然后对其进行相应的过滤。

 上面的图片展示了本节我们要实现的命令拦截效果。我们甚至只需要过滤具有特征性的参数调用。

首先我们需要明白的是,无论是新版还是旧版控制台(兼容性)他的控制台缓冲区数据都是在一个名为cmd.exe的进程中完成的。

尽管新版本引入了UWP界面的外壳组件:OpenConsole.exe、WindowsTerminal.exe,他们基本上处理图形界面(GUI)。

微软提供了很多接口用于控制台的高级应用。

这里我们需要谈的API主要有两个:

ReadConsole

从控制台输入缓冲区读取字符输入,并将其从缓冲区中删除。

这是一个很好的函数,它就像是一个内部的钩子一样,在用户将数据输入时候阻断命令的执行,并且将文本汇报给调用方。只不过这个函数只对当前序列的所有控制台线程有效,对远线程无效。

我们来看看它的原型:

BOOL WINAPI ReadConsole(
  _In_     HANDLE  hConsoleInput,
  _Out_    LPVOID  lpBuffer,
  _In_     DWORD   nNumberOfCharsToRead,
  _Out_    LPDWORD lpNumberOfCharsRead,
  _In_opt_ LPVOID  pInputControl
);

hConsoleInput [in]
控制台输入缓冲区的句柄。 该句柄必须具有 GENERIC_READ 访问权限。 有关详细信息,请参阅控制台缓冲区安全性和访问权限

lpBuffer [out]
指向接收从控制台输入缓冲区读取数据的缓冲区的指针。

nNumberOfCharsToRead [in]
要读取的字符数。 lpBuffer 参数指向的缓冲区的大小应至少nNumberOfCharsToRead * sizeof(TCHAR)为字节。

lpNumberOfCharsRead [out]
指向接收实际读取字符数的变量的指针。

pInputControl [in, 可选]
指向 CONSOLE_READCONSOLE_CONTROL 结构的指针,该结构指定要向读取操作末尾发出信号的控制字符。 此参数可以为 NULL。

此参数默认需要 Unicode 输入。 对于 ANSI 模式,请将此参数设置为 NULL

为了防止读取时缓冲区不足,一般初始化一个1024*2字节的缓冲区用于存放文本。

你可以这样调用:

setlocale(LC_ALL, "");// 使字符串中文支持

HANDLE hStdIn = GetStdHandle(STD_INPUT_HANDLE);// 获取控制台输入缓冲区的句柄
TCHAR cBuf[1024]{};// 定义一个字符串变量指针
DWORD ret = 0; // 用于存储实际读取到的文本长度
RtlZeroMemory(cBuf, sizeof(cBuf) / sizeof(TCHAR));// 初始化

ReadConsoleW(hStdIn, &cBuf, ARRAYSIZE(cBuf), &ret, NULL);// 拦截缓冲区输入

会读取也要会写:

WriteConsole

从当前光标位置开始,将字符串写入控制台屏幕缓冲区。

这个函数主要用于打印文本,类似于printf(" ")

函数原型:

BOOL WINAPI WriteConsole(
  _In_             HANDLE  hConsoleOutput,
  _In_       const VOID    *lpBuffer,
  _In_             DWORD   nNumberOfCharsToWrite,
  _Out_opt_        LPDWORD lpNumberOfCharsWritten,
  _Reserved_       LPVOID  lpReserved
);

你可以这样调用:

HANDLE hStdout;
DWORD dwChars;
TCHAR buf[] = _T("您好,世界,Helloworld");
int len = lstrlen(buf);
hStdout = GetStdHandle(STD_OUTPUT_HANDLE);// 获取输出句柄
if (hStdout==INVALID_HANDLE_VALUE)
{
    return -1;
}
WriteConsole(hStdout,buf,len,&dwChars,NULL);

然后就是一个很简单的一点,这个函数用于新版本输入文本的复原是无效的,如果要对拦截下来的命令进行恢复,我的方案是用管道执行命令然后返回执行结果,这个过程是同步执行的。

这段代码可以用于实现一个简单的执行管道:替代system()函数,

/// 参数一传入指令用法和system()函数相同,参数二返回最后执行结果的最后一行文本
/// 参数三返回参数二的长度
/// 执行过程的每一行都会实时打印
int _System(const char* cmd, char* pRetMsg, int msg_len)
{
	FILE* fp;
	char* p = NULL;
	int res = -1;
	if (cmd == NULL || pRetMsg == NULL || msg_len < 0)
	{
		printf("Param Error!\n");
		return -1;
	}
	if ((fp = _popen(cmd, "r")) == NULL)
	{
		printf("Popen Error!\n");
		return -2;
	}
	else
	{
		memset(pRetMsg, 0, msg_len);
		//get lastest result
		while (fgets(pRetMsg, msg_len, fp) != NULL)
		{
			printf("PippeMsg: %s", pRetMsg); //print all info
		}

		if ((res = _pclose(fp)) == -1)
		{
			printf("close popenerror!\n");
			return -3;
		}
		pRetMsg[strlen(pRetMsg) - 1] = '\0';
		return 0;
	}
}

不过还有一点需要注意的是,控制台窗口默认是有工作目录标志的C:\Users\UserName>。

每次管道执行完毕,新的标志符可能显示不出来,这时候需要发送回车键消息产生一个新的标识符。否则拦截将会无效(同一个标识符下只能拦截一次,这个小BUG不知道有没有更好的解决方法,望各位指点)。

然后就是解决发送回车消息的方法了。

这里使用SendInput函数向键盘队列插入新的按键消息。

SendInput 函数

合成击键、鼠标动作和按钮单击。

UINT SendInput(
  [in] UINT    cInputs,
  [in] LPINPUT pInputs,
  [in] int     cbSize
);

[in] cInputs

pInputs 数组中的结构数。

[in] pInputs

输入结构的数组。每个结构表示要插入到键盘或鼠标输入流中的事件。

[in] cbSize

输入结构的大小(以字节为单位)。如果 cbSize 不是 INPUT 结构的大小,则该函数将失败。

实现:

void SendEnter()
{
	INPUT inputs[2] = {};
	ZeroMemory(inputs, sizeof(inputs));

	inputs[0].type = INPUT_KEYBOARD;
	inputs[0].ki.wVk = VK_RETURN;

	inputs[1].type = INPUT_KEYBOARD;
	inputs[1].ki.wVk = VK_RETURN;
	inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;

	UINT uSent = SendInput(ARRAYSIZE(inputs), inputs, sizeof(INPUT));
	if (uSent != ARRAYSIZE(inputs))
	{
		//OutputString(L"SendInput failed: 0x%x\n", HRESULT_FROM_WIN32(GetLastError()));
		SendEnter();
	}
}

然而这时候我们需要切换窗口,不然回车键就有可能在其它应用中完成:

HWND hForeWnd = GetForegroundWindow();
DWORD dwForeID = GetWindowThreadProcessId(hForeWnd, NULL);
DWORD dwCurID = GetCurrentThreadId();

AttachThreadInput(dwCurID, dwForeID, TRUE);

ShowWindow(hwnd, SW_SHOWNORMAL);
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
SetForegroundWindow(hwnd);
Sleep(50);
SetFocus(hwnd);// 设置键盘输入点
Sleep(10);
SendEnter();//模拟回车键
Sleep(50);

// 复原
SetForegroundWindow(hForeWnd);
Sleep(50);
SetFocus(hForeWnd);// 设置键盘输入点

AttachThreadInput(dwCurID, dwForeID, FALSE);

这些函数操作都需要在要注入的进程中完成,不能在调用方完成。

一个简单的注入例子如下:(编译为控制台应用)

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

using namespace std;

/// <summary>
/// 根据进程名称获取进程信息
/// </summary>
/// <param name="info"></param>
/// <param name="processName"></param>
/// <returns></returns>
/// 
BOOL getProcess32Info(PROCESSENTRY32* info, const TCHAR processName[])
{
    HANDLE handle; //定义CreateToolhelp32Snapshot系统快照句柄
    handle = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);//获得系统快照句柄
    //PROCESSENTRY32 结构的 dwSize 成员设置成 sizeof(PROCESSENTRY32)
    info->dwSize = sizeof(PROCESSENTRY32);
    //调用一次 Process32First 函数,从快照中获取进程列表
    Process32First(handle, info);
    //重复调用 Process32Next,直到函数返回 FALSE 为止
    while (Process32Next(handle, info) != FALSE)
    {
        if (wcscmp(processName, info->szExeFile) == 0)
        {

            return TRUE;
        }
    }
    return FALSE;
}

BOOL ZwCreateThreadExInjectDll(DWORD dwProcessId, const wchar_t* pszDllFileName)
{
    int pathSize = (wcslen(pszDllFileName) + 1) * sizeof(wchar_t);
    // 1.打开目标进程
    HANDLE hProcess = OpenProcess(
        PROCESS_ALL_ACCESS, // 打开权限
        FALSE, // 是否继承
        dwProcessId); // 进程PID
    if (NULL == hProcess)
    {
        cout << L"打开目标进程失败!" << endl;
        return FALSE;
    }
    // 2.在目标进程中申请空间
    LPVOID lpPathAddr = VirtualAllocEx(
        hProcess, // 目标进程句柄
        0, // 指定申请地址
        pathSize, // 申请空间大小
        MEM_RESERVE | MEM_COMMIT, // 内存的状态
        PAGE_READWRITE); // 内存属性
    if (NULL == lpPathAddr)
    {
        cout << L"在目标进程中申请空间失败!" << endl;
        CloseHandle(hProcess);
        return FALSE;
    }
    // 3.在目标进程中写入Dll路径
    if (FALSE == WriteProcessMemory(
        hProcess, // 目标进程句柄
        lpPathAddr, // 目标进程地址
        pszDllFileName, // 写入的缓冲区
        pathSize, // 缓冲区大小
        NULL)) // 实际写入大小
    {
        cout << L"目标进程中写入Dll路径失败!" << endl;
        CloseHandle(hProcess);
        return FALSE;
    }

    //4.加载ntdll.dll
    HMODULE hNtdll = LoadLibraryW(L"ntdll.dll");
    if (NULL == hNtdll)
    {
        cout << L"加载ntdll.dll失败!" << endl;
        CloseHandle(hProcess);
        return FALSE;
    }

    //5.获取LoadLibraryA的函数地址
    //FARPROC可以自适应32位与64位
    FARPROC pFuncProcAddr = GetProcAddress(GetModuleHandle(L"Kernel32.dll"),
        "LoadLibraryW");
    if (NULL == pFuncProcAddr)
    {
        cout << L"获取LoadLibrary函数地址失败!" << endl;
        CloseHandle(hProcess);
        return FALSE;
    }

    //6.获取ZwCreateThreadEx函数地址,该函数在32位与64位下原型不同
    //_WIN64用来判断编译环境 ,_WIN32用来判断是否是Windows系统
#ifdef _WIN64
    typedef DWORD(WINAPI* typedef_ZwCreateThreadEx)(
        PHANDLE ThreadHandle,
        ACCESS_MASK DesiredAccess,
        LPVOID ObjectAttributes,
        HANDLE ProcessHandle,
        LPTHREAD_START_ROUTINE lpStartAddress,
        LPVOID lpParameter,
        ULONG CreateThreadFlags,
        SIZE_T ZeroBits,
        SIZE_T StackSize,
        SIZE_T MaximumStackSize,
        LPVOID pUnkown
        );
#else
    typedef DWORD(WINAPI* typedef_ZwCreateThreadEx)(
        PHANDLE ThreadHandle,
        ACCESS_MASK DesiredAccess,
        LPVOID ObjectAttributes,
        HANDLE ProcessHandle,
        LPTHREAD_START_ROUTINE lpStartAddress,
        LPVOID lpParameter,
        BOOL CreateSuspended,
        DWORD dwStackSize,
        DWORD dw1,
        DWORD dw2,
        LPVOID pUnkown
        );
#endif 
    typedef_ZwCreateThreadEx ZwCreateThreadEx =
        (typedef_ZwCreateThreadEx)GetProcAddress(hNtdll, "ZwCreateThreadEx");
    if (NULL == ZwCreateThreadEx)
    {
        cout << L"获取ZwCreateThreadEx函数地址失败!" << endl;
        CloseHandle(hProcess);
        return FALSE;
    }
    //7.在目标进程中创建远线程
    HANDLE hRemoteThread = NULL;
    DWORD dwStatus = ZwCreateThreadEx(&hRemoteThread, PROCESS_ALL_ACCESS, NULL,
        hProcess,
        (LPTHREAD_START_ROUTINE)pFuncProcAddr, lpPathAddr, 0, 0, 0, 0, NULL);
    if (NULL == hRemoteThread)
    {
        cout << L"目标进程中创建线程失败!" << endl;
        CloseHandle(hProcess);
        return FALSE;
    }

    // 8.等待线程结束
    WaitForSingleObject(hRemoteThread, -1);
    // 9.清理环境
    VirtualFreeEx(hProcess, lpPathAddr, 0, MEM_RELEASE);
    CloseHandle(hRemoteThread);
    CloseHandle(hProcess);
    FreeLibrary(hNtdll);
    return TRUE;
}

// 要注入dll的完整路径
const wchar_t dllPath[] = L"D:\\repos\\hcpCmdIde.dll";

BOOL GetAITime(HANDLE hProcess) {
    FILETIME loadStartTime, exitTime, kernelTime, userTime;
    // 获取进程时间信息
    ::GetProcessTimes(hProcess, &loadStartTime, &exitTime, &kernelTime, &userTime);

    SYSTEMTIME stCreation{};
    SYSTEMTIME lstCreation{};
    // 定义为中国北京时区的信息
    TIME_ZONE_INFORMATION DEFAULT_TIME_ZONE_INFORMATION = { -480 };
    // 文件记录时间转换为系统时间
    ::FileTimeToSystemTime(&loadStartTime, &stCreation);
    // 系统时间转换为中国北京时区时间
    SystemTimeToTzSpecificLocalTime(&DEFAULT_TIME_ZONE_INFORMATION, &stCreation, &lstCreation);
   // 打印具体时间
    printf("StartTime:\t %u-%u-%u %02d:%02d:%02d\n", 
        lstCreation.wYear,
        lstCreation.wMonth,
        lstCreation.wDay,
        lstCreation.wHour,
        lstCreation.wMinute,
        lstCreation.wSecond);
    return TRUE;
}



int main()
{
    SetConsoleTitleW(L"TASDPI");// 设置标题
    PROCESSENTRY32 info;
    // 查找指定进程名的PID
    if (getProcess32Info(&info, L"cmd.exe"))// 要注入cmd进程
    {
          HANDLE hCmd = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, info.th32ProcessID);
          
          printf("Hook PTD[%d]\n", info.th32ProcessID);
          GetAITime(hCmd);// 获取指定cmd进程启动的时刻

                                // 进程标识符PID。
          ZwCreateThreadExInjectDll(info.th32ProcessID, dllPath);
          cout << "注入成功" << endl;
    }
    else {
        cout << "注入失败" << endl;
    }

    cin.get();
    return 0;
}

然后就是DLL的代码:

#include "pch.h"
#include <cstdio>
#include <windows.h>
#include <stdio.h>
#include <tchar.h>
#include <Psapi.h>
#include <string.h>
#include <TlHelp32.h>
#include <Shlwapi.h>
#include <clocale>
#include <regex>
using namespace std;

#pragma comment(lib,"Shlwapi.lib")
#pragma comment ( lib, "Psapi.lib" )


//共享代码段
#pragma data_seg("SHARED")
int ConMode = 0;// 进程模式
#pragma data_seg()
#pragma comment(linker, "/section:SHARED,RWS")

void SendEnter()
{
	INPUT inputs[2] = {};
	ZeroMemory(inputs, sizeof(inputs));

	inputs[0].type = INPUT_KEYBOARD;
	inputs[0].ki.wVk = VK_RETURN;

	inputs[1].type = INPUT_KEYBOARD;
	inputs[1].ki.wVk = VK_RETURN;
	inputs[1].ki.dwFlags = KEYEVENTF_KEYUP;

	UINT uSent = SendInput(ARRAYSIZE(inputs), inputs, sizeof(INPUT));
	if (uSent != ARRAYSIZE(inputs))
	{
		//OutputString(L"SendInput failed: 0x%x\n", HRESULT_FROM_WIN32(GetLastError()));
		SendEnter();
	}
}

string ws2s(const wstring& ws)
{
	setlocale(LC_ALL, "chs");
	const wchar_t* wcs = ws.c_str();
	size_t dByteNum = sizeof(wchar_t) * ws.size() + 1;
	//cout << "ws.size():" << ws.size() << endl;

	char* dest = new char[dByteNum];
	wcstombs_s(NULL, dest, dByteNum, wcs, _TRUNCATE);
	string result = dest;
	delete[] dest;
	return result;
}

int _System(const char* cmd, char* pRetMsg, int msg_len)
{
	FILE* fp;
	char* p = NULL;
	int res = -1;
	if (cmd == NULL || pRetMsg == NULL || msg_len < 0)
	{
		printf("Param Error!\n");
		return -1;
	}
	if ((fp = _popen(cmd, "r")) == NULL)
	{
		printf("Popen Error!\n");
		return -2;
	}
	else
	{
		memset(pRetMsg, 0, msg_len);
		//get lastest result
		while (fgets(pRetMsg, msg_len, fp) != NULL)
		{
			printf("%s", pRetMsg); // print all info
		}

		if ((res = _pclose(fp)) == -1)
		{
			printf("close popenerror!\n");
			return -3;
		}
		pRetMsg[strlen(pRetMsg) - 1] = '\0';
		return 0;
	}
}



BOOL SwitchToHandleCommandProcessMainWindow(HWND hwnd)
{

	if (!ConMode) {
		printf("GetConModeError");
		return FALSE;
	}

	if (hwnd != NULL) {
		//printf("HWND %08p\n", hwnd);
		HWND hForeWnd = ::GetForegroundWindow();
		DWORD dwForeID = ::GetWindowThreadProcessId(hForeWnd, NULL);
		DWORD dwCurID = ::GetCurrentThreadId();
		::AttachThreadInput(dwCurID, dwForeID, TRUE);
		::ShowWindow(hwnd, SW_SHOWNORMAL);
		::SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
		::SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, SWP_NOSIZE | SWP_NOMOVE);
		::SetForegroundWindow(hwnd);
		Sleep(50);
		::SetFocus(hwnd);// 设置键盘输入点
		Sleep(10);
		SendEnter();//模拟回车键
		Sleep(50);
		// 复原
		::SetForegroundWindow(hForeWnd);
		Sleep(50);
		::SetFocus(hForeWnd);// 设置键盘输入点
		::AttachThreadInput(dwCurID, dwForeID, FALSE);
	}
	else
		return FALSE;
	//ShowWindow(lmshwnd, SW_MAXIMIZE);
	return TRUE;
}



BOOL SetObjectCommandHook() {
	constexpr int length = 15;
	const auto characters = TEXT("abcdefghi9182345jklmnopqrstuv211935960473wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890");
	TCHAR nwsName[length + 1]{};
	TCHAR OleName[MAX_PATH + 1]{};
	srand((int)time(0));// 根据系统时间生成随机种子
	for (int j = 0; j != length; j++)
	{
		nwsName[j] += characters[rand() % 80];
	}

	GetConsoleTitleW(OleName, MAX_PATH);// 备份
	Sleep(100);
	SetConsoleTitleW(nwsName);// 重置窗口标题
	Sleep(300);
	HWND hwnds = FindWindow(L"CASCADIA_HOSTING_WINDOW_CLASS", nwsName);
	if (hwnds == NULL) {
		hwnds = FindWindow(L"ConsoleWindowClass", nwsName);
		ConMode = 2;
	}
	else {
		ConMode = 1;
	}

	SwitchToHandleCommandProcessMainWindow(hwnds);// 使得控制台缓冲区输入Hook立即生效
	SetConsoleTitleW(OleName);// 重置窗口标题
	wprintf(L"@RioCth Hook Command! Happy!");
	SendEnter();//模拟回车键
	return TRUE;
}


void InjectProc(void) {
	setlocale(LC_ALL, "");
	HANDLE hStdIn = GetStdHandle(STD_INPUT_HANDLE);

	TCHAR cBuf[1024]{};
	while (TRUE) {
		/* Display Options */
	    DWORD ret = 0;

		do
		{
			RtlZeroMemory(cBuf, sizeof(cBuf) / sizeof(TCHAR));
			ReadConsoleW(hStdIn, &cBuf, ARRAYSIZE(cBuf), &ret, NULL);
			
            // 根据参数的关键特征进行判断,从而拦截
			if (StrStrIW(cBuf, L"/IM") || StrStrIW(cBuf, L"/PID") || StrStrIW(cBuf, L"/F") || StrStrIW(cBuf, L"/T")) {
				if (StrStrIW(cBuf, L"explorer")) {

					time_t nowtime;
					struct tm p[40]{};
					time(&nowtime);
					localtime_s(p, &nowtime);

					wprintf(L"[Time\t%d-%d-%d:%02d:%02d:%02d]\nCommandLine: %s 操作未能完成,原因:拒绝访问。",
						1900 + p->tm_year, 1 + p->tm_mon, p->tm_mday,
						p->tm_hour, p->tm_min, p->tm_sec, cBuf);

					SendEnter();//模拟回车键

				}else if (0 != _wcsicmp(cBuf, L"\n")) {

					cBuf[wcslen(cBuf) - 1] = L'\0';
					char PipeResult[256]{};
                    // 用管道执行原始命令
					ret = _System(ws2s(cBuf).c_str(), PipeResult, sizeof(PipeResult));
					SendEnter();//后面不能使用Sleep否则有BUG
				}
			}
			else if(0 != _wcsicmp(cBuf, L"\n")) {

				cBuf[wcslen(cBuf) - 1] = L'\0';
				char PipeResult[256]{};
                // 用管道执行原始命令
				ret = _System(ws2s(cBuf).c_str(), PipeResult, sizeof(PipeResult));
				SendEnter();
			}
			
		} while (0 != wcscmp(cBuf, L"\0"));//后面不能使用Sleep否则有BUG

	}
	return ;
}



BOOL APIENTRY DllMain(HMODULE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved) {
	HANDLE hThread = NULL;
	
	DWORD threadID1 = 0;
	int Counter = 0;
	
	switch (ul_reason_for_call) {
	case DLL_PROCESS_ATTACH:
		hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)InjectProc, NULL, 0, &threadID1);
		while (!hThread && Counter < 5) {
			hThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)InjectProc, NULL, 0, &threadID1);
			Counter++;
		}
		break;
	case DLL_THREAD_ATTACH:
	case DLL_THREAD_DETACH:
	case DLL_PROCESS_DETACH:
		break;
	}
	return TRUE;
}

以上就是通过Dll注入实现屏蔽控制台某种指令的方法。

转载请注明出处:@涟幽516

博客:Dll注入过滤任意Windows控制台命令行输入

更新时间:2023.1.29

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

涟幽516

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值