涛哥的blog http://www.138soft.com Sat, 07 Jan 2023 14:57:13 +0000 zh-CN hourly 1 https://wordpress.org/?v=6.2.2 http://www.138soft.com/wp-content/uploads/2018/03/cropped-timg-32x32.jpg 涛哥的blog http://www.138soft.com 32 32 RDP通道(远程桌面插件)开发指南—Delphi版 http://www.138soft.com/?p=609 http://www.138soft.com/?p=609#respond Thu, 05 Jan 2023 06:51:19 +0000 http://www.138soft.com/?p=609 Windows的远程桌面服务,为了方便第三方开发者,对外提供了插件功能,也即RDP虚拟通道(VirtualChannels)接口。 RDP虚拟通道由两部分组成:客户端和服务端,其中客户端通道一般写成DLL形式,然后写入注册表登记,当用户运行远程桌面客户端的时候,就会自动加载它;服务端通道一般写成一个EXE,用户登录后运行它,它将会和客户端的DLL进行通信。RDP虚拟通道分为静态通道(StaticVirtualChannels)和动态通道(DynamicVirtualChannels)两种,下面分别介绍。

一、静态通道(StaticVirtualChannels)

(一)客户端

1、客户端DLL文件的注册

必须将客户端 DLL 的名称存储在注册表中。在注册表中,将子项添加到以下位置之一:

HKEY_CURRENT_USER\Software\Microsoft\Terminal Server Client\Default\Addins
HKEY_CURRENT_USER\Software\Microsoft\Terminal Server Client\connection\Addins

\Default\Addins项下的条目适用于所有连接。\connection\Addins项下的条目仅适用于由 connection 标识的连接。可以使用连接管理器创建和管理连接。可以为子项指定任何名称。它必须包含REG_SZ或REG_EXPAND_SZ值,并且可以选择包含REG_DWORD值。

REG_SZ或REG_EXPAND_SZ值的语法如下:

Name = DLLname

如果Name是一个REG_EXPAND_SZ值,它可以包含在运行时扩展的未扩展环境变量。DLLname的值可以是完全限定路径。如果DLLname不包含路径,则使用标准 DLL 搜索策略。有关详细信息,请参阅LoadLibrary的备注部分。

REG_DWORD值的原型如下:

RemoteControlPersistent = flag

标志的值可以是1或0。0是默认值。如果设置为 1,则在客户端会话启动或停止时不会通知服务应用程序。如果设置为0,则在客户端会话开始时发出 RECONNECT事件信号,并在客户端会话停止时发出 DISCONNECT 事件信号。

下面是一个从注册表导出的例子:

Windows Registry Editor Version 5.00

[HKEY_CURRENT_USER\Software\Microsoft\Terminal Server Client\Default\AddIns\Test1]
“Name”=”C:\\SVCClient.dll”

2、客户端DLL的编写

2.1 初始化

客户端Dll必须对外提供一个函数,名称为VirtualChannelEntry。函数定义如下:


function VirtualChannelEntry({[in]}pEntryPoints: PCHANNEL_ENTRY_POINTS): BOOL; stdcall;

远程桌面客户端加载这个DLL后,会先调用这个函数,并传递一个指向 CHANNEL_ENTRY_POINTS 结构的指针,该结构包含指向客户端虚拟通道函数的指针。该指针在VirtualChannelEntry函数返回后不再有效 。所以必须在这里复制一个备份,后面的函数会使用到。
VirtualChannelEntry实现必须调用 VirtualChannelInit 函数来初始化对虚拟通道的访问。
如果这个函数返回TRUE ,说明成功。如果发生错误,则返回FALSE 。在这种情况下,远程桌面服务将卸载这个DLL。

我们来看一下PCHANNEL_ENTRY_POINTS这个结构的定义:

type
tagCHANNEL_ENTRY_POINTS = record
cbSize: DWORD;//此结构的大小(以字节为单位)。
protocolVersion: DWORD;//协议版本。远程桌面服务将此成员设置为VIRTUAL_CHANNEL_VERSION_WIN2000。
pVirtualChannelInit: VIRTUALCHANNELINIT; //PVIRTUALCHANNELINIT;//指向 VirtualChannelInit函数的指针。
pVirtualChannelOpen: VIRTUALCHANNELOPEN; //PVIRTUALCHANNELOPEN;//指向 VirtualChannelOpen函数的指针
pVirtualChannelClose: VIRTUALCHANNELCLOSE; //PVIRTUALCHANNELCLOSE;//指向 VirtualChannelClose函数的指针。
pVirtualChannelWrite: VIRTUALCHANNELWRITE; //PVIRTUALCHANNELWRITE;//指向 VirtualChannelWrite函数的指针。
end;
CHANNEL_ENTRY_POINTS = tagCHANNEL_ENTRY_POINTS;
PCHANNEL_ENTRY_POINTS = ^tagCHANNEL_ENTRY_POINTS;

因为这里我们必须先调用一次 VirtualChannelInit 函数来初始化对虚拟通道的访问,所以看一下这个函数的定义:

type
VIRTUALCHANNELINIT = function(
{[in]}ppInitHandle: PPointer;//指向接收标识客户端连接的句柄的变量的指针。使用此句柄在对VirtualChannelOpen函数的后续调用中识别客户端 。
{[in, out]}pChannel: PCHANNEL_DEF;//指向CHANNEL_DEF 结构数组的指针。每个结构包含客户端 DLL 将打开的虚拟通道的名称和初始化选项。
//请注意,VirtualChannelInit调用不会打开这些虚拟通道;它仅保留此应用程序使用的名称。
{[in]}channelCount: Integer;//指定pChannel数组中的条目数。
{[in]}versionRequested: ULONG;//指定虚拟通道支持的级别。将此参数设置为VIRTUAL_CHANNEL_VERSION_WIN2000。
{[in]}pChannelInitEventProc: PCHANNEL_INIT_EVENT_FN//指向应用程序定义的 VirtualChannelInitEvent函数的指针,远程桌面
//服务调用该函数以将虚拟通道事件通知客户端 DLL。
): UINT; stdcall;

这个函数的作用,是初始化客户端 DLL 对远程桌面服务虚拟通道的访问。客户端调用VirtualChannelInit来注册其虚拟通道的名称。
只能从 VirtualChannelEntry 函数调用 VirtualChannelInit函数。在任何其他时间调用VirtualChannelInit都会失败。
如果函数成功,返回值为CHANNEL_RC_OK。
当VirtualChannelInit成功返回时,远程桌面服务已注册请求的频道。但是,远程桌面服务可能还没有完成其他初始化。当所有初始化完成后,远程桌面服务 使用CHANNEL_EVENT_INITIALIZED事件调用你的VirtualChannelInitEvent 回调函数。在调用此函数之前,你不应该对可用虚拟通道的数量进行假设,因为系统和其他插件可能已经预留了虚拟通道。因此,应该在调用此函数后始终检查CHANNEL_RC_TOO_MANY_CHANNELS返回码。当VirtualChannelInit返回时,如果通道已成功初始化,则每个CHANNEL_DEF结构 的选项成员
包括CHANNEL_OPTION_INITIALIZED 。
每个客户端会话的最大通道数是CHANNEL_MAX_COUNT( 30)。

再来看一下CHANNEL_DEF的定义:


type
tagCHANNEL_DEF = packed record
name: array[0..CHANNEL_NAME_LEN] of AnsiChar;//包含虚拟通道名称的以 Null 结尾的字符串。虚拟频道名称可以包含 1 到 CHANNEL_NAME_LEN 个字符。
options: ULONG;//指定此虚拟通道的选项
end;

拟通道的选项,下表显示了可以组合使用的可能值:

CHANNEL_OPTION_ENCRYPT_CS 加密客户端到服务器的数据。
CHANNEL_OPTION_ENCRYPT_RDP 根据远程桌面协议 (RDP) 数据加密进行加密。也就是说,如果 RDP 数据是加密的,那么也对该通道进行加密。
CHANNEL_OPTION_INITIALIZED 通道被初始化。该值由VirtualChannelInit或VirtualChannelInitEx函数设置。
CHANNEL_OPTION_REMOTE_CONTROL_PERSISTENT 该通道被声明为远程控制持久。这意味着当一个会话开始隐藏当前会话时,或者当远程控制连接到连接到客户端的会话或从连接到客户端的会话断开连接时,通道不会在服务器端关闭。有关详细信息,请参阅远程控制持久虚拟通道。
CHANNEL_OPTION_SHOW_PROTOCOL 影响服务器端接收VirtualChannelWrite发送的数据的方式。如果设置了这个值,每个数据块前面都有一个 CHANNEL_PDU_HEADER 结构。如果未设置此值,则数据块仅包含指定给VirtualChannelWrite的数据。

下面是一个典型的例子:


var
//全局变量定义
g_EntryPoints: CHANNEL_ENTRY_POINTS;
g_pInitHandle: Pointer;

const
pszChannelName='MyTestChannel';//通道的名称,必须跟服务端一致

function VirtualChannelEntry({[in]}pEntryPoints: PCHANNEL_ENTRY_POINTS): BOOL; stdcall;
var
Channel: CHANNEL_DEF;
ulRet: UINT;
begin
g_EntryPoints := pEntryPoints^; //先保存指针

FillChar(Channel, sizeof(Channel), 0);
lstrcpyA(Channel.name, pszChannelName);

ulRet := g_EntryPoints.pVirtualChannelInit(@g_pInitHandle, @Channel, 1, VIRTUAL_CHANNEL_VERSION_WIN2000, @VirtualChannelInitEvent);
Result := ulRet = CHANNEL_RC_OK;
if not Result then Exit;
if (Channel.options and CHANNEL_OPTION_INITIALIZED) = 0 then Result := False;
end;

因为一个插件本身可以支持多个通道,我们将它修改一下,变成多个通道:

const
DEFChannelsCount = 2;
var
g_EntryPoints: CHANNEL_ENTRY_POINTS;
g_pInitHandle: Pointer;
g_ChannelDef: array[0..DEFChannelsCount - 1] of CHANNEL_DEF =
(
(name: 'MyTest1'; options: 0),
(name: 'MyTest2'; options: 0)
);

function VirtualChannelEntry({[in]}pEntryPoints: PCHANNEL_ENTRY_POINTS): BOOL; stdcall;
var
ulRet: UINT;
i: Integer;
dwID: DWORD;
begin
g_EntryPoints := pEntryPoints^;//先保存指针

ulRet := g_EntryPoints.pVirtualChannelInit(@g_pInitHandle, @g_ChannelDef[0], DEFChannelsCount, VIRTUAL_CHANNEL_VERSION_WIN2000, @VirtualChannelInitEvent);
Result := ulRet = CHANNEL_RC_OK;
if Result then
begin
for i := 0 to DEFChannelsCount - 1 do
if (g_ChannelDef[i].options and CHANNEL_OPTION_INITIALIZED) = 0 then
begin
Result := False;
Exit;
end;
end;
end;

2.2 虚拟通道事件

我们在上一节的初始化函数里面传递了一个自定义回调函数VirtualChannelInitEvent,这个函数的原型为:


type
CHANNEL_INIT_EVENT_FN = procedure(
pInitHandle: Pointer;//处理客户端连接。这是在VirtualChannelInit函数的ppInitHandle参数中 返回的句柄。
event: UINT;//指示导致通知的事件 。
pData: Pointer;//指向事件的附加数据的指针。数据类型取决于事件 。
dataLength: UINT//指定pData缓冲区中数据的大小(以字节为单位) 。
); stdcall;

其中事件的参数可以是以下值之一:

CHANNEL_EVENT_INITIALIZED (0):远程桌面连接 (RDC) 客户端初始化已完成。pData参数为NULL。
CHANNEL_EVENT_CONNECTED (1):已与支持虚拟通道的 RD 会话主机服务器建立连接。pData参数是指向具有服务器名称的空终止字符串的 指针。
CHANNEL_EVENT_V1_CONNECTED (2):已与不支持虚拟通道的 RD 会话主机服务器建立连接。pData参数为NULL。
CHANNEL_EVENT_DISCONNECTED (3):与 RD 会话主机服务器的连接已断开。pData参数为NULL。
CHANNEL_EVENT_TERMINATED (4):客户端已终止。pData参数为NULL。
CHANNEL_EVENT_REMOTE_CONTROL_START (5):远程控制操作已经开始。pData参数为NULL。
CHANNEL_EVENT_REMOTE_CONTROL_STOP (6):远程控制操作已终止。pData参数是指向 包含服务器名称的空终止字符串的指针。

其中我们关心的是CHANNEL_EVENT_CONNECTED和CHANNEL_EVENT_TERMINATED事件。在CHANNEL_EVENT_CONNECTED事件中,我们需要
打开我们的通道、关联收发数据回调,而在CHANNEL_EVENT_TERMINATED事件里面释放我们需要释放的资源。

因为我们的插件存在多个通道,每个通道有一个句柄,所以先定义一个结构:

type
TChannelRecord = record
Handle: DWORD;//不管32位还是64位,这个都是DWORD,其实是一个标识符而不是传统意义上的句柄
InputBuffer: PAnsiChar;
InputBufferSize: Cardinal;
InputBufferOffset: Cardinal;
OutputBuffer: PAnsiChar;
OutputBufferSize: Cardinal;
OutputBufferOffset: Cardinal;
//procedure Write;
end;

结构的一些参数我们后面用到的时候再说明。再定义一个全局数组:

var
g_ChannelRecord: array[0..DEFChannelsCount - 1] of TChannelRecord;

另外,为了直观操作,我们加入了一个VCL窗口,名称为FrmMain,目前只有一个函数用于输出信息:procedure ShowInfo(const strInfo: string);

现在,可以编写我们的回调函数了:

procedure VirtualChannelInitEvent(
pInitHandle: Pointer;
event: UINT;
pData: Pointer;
dataLength: UINT); stdcall;
var
i: Integer;
ulRet: UINT;
begin
case event of
CHANNEL_EVENT_INITIALIZED:
begin
FrmMain.ShowInfo('initialized!');
end;
CHANNEL_EVENT_CONNECTED:
begin
FrmMain.ShowInfo('connected!');
for i := 0 to DEFChannelsCount - 1 do
begin
ulRet := g_EntryPoints.pVirtualChannelOpen(pInitHandle, @g_ChannelRecord[i].Handle, g_ChannelDef[i].name, @VirtualChannelOpenEvent);
if ulRet <> CHANNEL_RC_OK then FrmMain.ShowInfo('VirtualChannelOpen Error!');
end;
end;
CHANNEL_EVENT_V1_CONNECTED:
begin
FrmMain.ShowInfo('v1 connected!');
end;
CHANNEL_EVENT_DISCONNECTED:
begin
FrmMain.ShowInfo('desconnected!');
end;
CHANNEL_EVENT_TERMINATED:
begin
FrmMain.ShowInfo('terminated!');
for i := 0 to DEFChannelsCount - 1 do
begin
g_EntryPoints.pVirtualChannelClose(g_ChannelRecord[i].Handle);
end;
end;
else
begin
FrmMain.ShowInfo('unknow event1!');
end;
end;
end;

在CHANNEL_EVENT_CONNECTED事件里面,我们调用函数VirtualChannelOpen打开虚拟通道的客户端。该函数原型为:

type
VIRTUALCHANNELOPEN = function(
{[in]}pInitHandle: Pointer;//处理客户端连接。这是在VirtualChannelInit函数的ppInitHandle参数中 返回的句柄。
{[out]}pOpenHandle: PDWORD;//指向一个变量的指针,该变量接收一个句柄,该句柄在随后调用 VirtualChannelWrite和 VirtualChannelClose函数时标识打开的虚拟通道。
{[in]}pChannelName: PAnsiChar;//指向包含要打开的虚拟通道名称的以 null 结尾的 ANSI 字符串的指针。该名称必须在客户端调用 VirtualChannelInit函数时注册。
{[in]}pChannelOpenEventProc: PCHANNEL_OPEN_EVENT_FN//指向应用程序定义的 VirtualChannelOpenEvent函数的指针,远程桌面服务调用该函数以将此虚拟通道的事件通知客户端 DLL。
): UINT; stdcall;

如果函数成功,返回值为 CHANNEL_RC_OK。

再来看最后一个参数 VirtualChannelOpenEvent函数的原型:

type
CHANNEL_OPEN_EVENT_FN = procedure(
{[in]}openHandle: DWORD;//处理虚拟通道。这是VirtualChannelOpen 函数的pOpenHandle 参数中返回的句柄。
{[in]}event: UINT;//指示导致通知的事件。
{[in]}pData: Pointer;//指向事件的附加数据的指针。数据类型取决于事件。
{[in]}dataLength: UINT32;//指定pData缓冲区中数据的大小(以字节为单位) 。
{[in]}totalLength: UINT32;//指定通过单个写操作写入虚拟通道服务器端的数据的总大小(以字节为单位)。
{[in]}dataFlags: UINT32//提供有关在 CHANNEL_EVENT_DATA_RECEIVED事件中接收的数据块的信息。
); stdcall;

其中:
第二个参数event可以是以下值之一:

CHANNEL_EVENT_DATA_RECEIVED:虚拟通道从服务器端接收数据。pData是指向数据块的指针。dataLength指示此块的大小。 totalLength表示服务器写入数据的总大小。
CHANNEL_EVENT_WRITE_CANCELLED:由 VirtualChannelWrite调用启动的写操作已被取消。pData是在VirtualChannelWrite的pUserData 参数中指定的值。当客户端会话断开时,写操作被取消。此通知使你能够释放与写入操作关联的任何内存。
CHANNEL_EVENT_WRITE_COMPLETE:由 VirtualChannelWrite调用启动的写操作已经完成。pData是在 VirtualChannelWrite的pUserData参数中 指定的值。

第三个参数pData指向事件的附加数据的指针,数据类型取决于事件,如前面事件描述中所述:

如果事件是CHANNEL_EVENT_DATA_RECEIVED,则服务器写入的数据被分成不超过CHANNEL_CHUNK_LENGTH字节的块。dataFlags 参数指示当前块是在服务器写入的数据块的开头、中间还是结尾。请注意,此参数的大小可以大于dataLength参数指定的值。应用程序应该只读取dataLength指定的字节数。

最后一个参数dataFlags提供有关在 CHANNEL_EVENT_DATA_RECEIVED事件中接收的数据块的信息。将设置以下位标志:

CHANNEL_FLAG_FIRST:chunk 是单个写操作写入的数据的开始。比较此标志时使用按位比较。
CHANNEL_FLAG_LAST:chunk 是单次写操作写入的数据的结尾。比较此标志时使用按位比较。
CHANNEL_FLAG_MIDDLE:这是默认设置。该块位于单个写入操作写入的数据块的中间。不要使用按位比较直接比较这个标志值。相反,使用按位比较来确定标志值不是CHANNEL_FLAG_FIRST或
CHANNEL_FLAG_LAST。这是通过使用以下比较来完成的: Result := (flags and CHANNEL_FLAG_FIRST=0) and (flags and CHANNEL_FLAG_LAST=0)。
CHANNEL_FLAG_ONLY:组合CHANNEL_FLAG_FIRST和CHANNEL_FLAG_LAST 值。该块包含来自单个写操作的所有数据。比较此标志时使用按位比较。

我们的代码如下:

procedure VirtualChannelOpenEvent(
openHandle: DWORD;
event: UINT;
pData: Pointer;
dataLength: UINT32;
totalLength: UINT32;
dataFlags: UINT32); stdcall;
var
pChannelRecord: LPChannelRecord;
nIndex: Integer;
begin

//先查找是哪个通道的事件
pChannelRecord := nil;
for nIndex := 0 to DEFChannelsCount - 1 do
begin
if g_ChannelRecord[nIndex].Handle = openHandle then
begin
pChannelRecord := @g_ChannelRecord[nIndex];
Break;
end;
end;
if pChannelRecord = nil then
begin
FrmMain.ShowInfo('Can not Found ChannelRecord!');
Exit;
end;

case event of
CHANNEL_EVENT_DATA_RECEIVED:
begin
FrmMain.ShowInfo(Format('chaneel%d recv data!', [nIndex]));

if ((dataFlags and CHANNEL_FLAG_FIRST) <> 0) then//第一个数据包,分配接收缓冲区
begin
if pChannelRecord^.InputBuffer <> nil then FreeMemory(pChannelRecord^.InputBuffer);
pChannelRecord^.InputBuffer := GetMemory(totalLength);
pChannelRecord^.InputBufferOffset := 0;
pChannelRecord^.InputBufferSize := totalLength;
FrmMain.ShowInfo('Input Buffer allocated');
end;

if pChannelRecord^.InputBuffer = nil then
begin
FrmMain.ShowInfo('Internal error. No buffer allocated.');
Exit;
end;

if pChannelRecord^.InputBufferOffset + dataLength <= pChannelRecord^.InputBufferSize then
begin
Move(pData^, (pChannelRecord^.InputBuffer + pChannelRecord^.InputBufferOffset)^, dataLength);
Inc(pChannelRecord^.InputBufferOffset, dataLength);
end
else
begin
FrmMain.ShowInfo('Data received exceeds buffer size');
end;

if (dataFlags and CHANNEL_FLAG_LAST) <> 0 then//数据接收完毕
begin
FrmMain.ShowInfo(Format('chaneel%d Buffer complete!', [nIndex]));
ProcessRecvData(pChannelRecord);//处理过程
FreeMemory(pChannelRecord^.InputBuffer);
pChannelRecord^.InputBuffer := nil;
end;
end;

CHANNEL_EVENT_WRITE_COMPLETE:
begin
FrmMain.ShowInfo(Format('chaneel%d write compltet!', [nIndex]));
FreeMemory(pChannelRecord^.OutputBuffer);
pChannelRecord^.OutputBuffer := nil;
end;
CHANNEL_EVENT_WRITE_CANCELLED:
begin
FrmMain.ShowInfo(Format('chaneel%d write canceled!', [nIndex]));
FreeMemory(pChannelRecord^.OutputBuffer);
pChannelRecord^.OutputBuffer := nil;
end;
else
begin
FrmMain.ShowInfo(Format('chaneel%d unknow event2!', [nIndex]));
end;
end;
end;

其中数据处理过程里面,我们只是简单的将数据回发给服务端:

procedure ProcessRecvData(pChannelRecord: LPChannelRecord);
begin
SendDataToServer(pChannelRecord^.InputBuffer, pChannelRecord^.InputBufferSize, pChannelRecord);
end;

procedure SendDataToServer(pData: Pointer; dwSize: DWORD; pChannelRecord: LPChannelRecord);
var
ulRet: UINT;
begin
if pChannelRecord^.OutputBuffer <> nil then
begin
FrmMain.ShowInfo('The channel last time send data not COMPLETE!');
Exit;
end;
pChannelRecord^.OutputBuffer := GetMemory(dwSize);
CopyMemory(pChannelRecord^.OutputBuffer, pData, dwSize);
ulRet := g_EntryPoints.pVirtualChannelWrite(pChannelRecord^.Handle,
pChannelRecord^.OutputBuffer,
dwSize,
nil);
if ulRet <> CHANNEL_RC_OK then FrmMain.ShowInfo(Format('Write failed! Error = %d.!', [ulRet]));
end;

这里,数据发送的函数原型为:

type
VIRTUALCHANNELWRITE = function(
{[in]}openHandle: DWORD;//处理虚拟通道。这是VirtualChannelOpen函数的pOpenHandle参数中 返回的句柄。
{[in]}pData: Pointer;//指向包含要写入的数据的缓冲区的指针。
{[in]}dataLength: ULONG;//指定要写入的pData缓冲区中数据的字节数。
{[in]}pUserData: Pointer//应用程序定义的值。当写入操作完成或取消时,此值将传递给你的 VirtualChannelOpenEvent函数。
): UINT; stdcall;

如果函数成功,返回值为 CHANNEL_RC_OK。

VirtualChannelWrite函数是异步的 。写入操作完成后,你的 VirtualChannelOpenEvent函数会收到 CHANNEL_EVENT_WRITE_COMPLETE 通知。在收到该通知之前,调用者不得释放或重新使用传递给 VirtualChannelWrite的pData缓冲区。当写操作完成或取消时,为pUserData参数指定的值将传递给你的 VirtualChannelOpenEvent函数。你可以使用此数据来识别写操作。虚拟通道服务器端的服务器插件调用 WTSVirtualChannelRead函数来读取由 VirtualChannelWrite调用写入的数据。

3、服务端插件程序编写

客户端插件DLL编译、安装完毕后(需要注意远程客户端的位数,如果是64位系统,必须编译为64位的DLL),我们来编写服务端插件。服务端插件可以是用户应用层EXE形式,也可以是服务程序等,在客户端连接后,即可进行操作。

3.1 打开/连接客户端虚拟通道

打开客户端的虚拟通道使用函数WTSVirtualChannelOpen,函数原型为:

function WTSVirtualChannelOpen(
{[in]}hServer: THANDLE;//此参数必须是 WTS_CURRENT_SERVER_HANDLE
{[in]}SessionId: DWORD;//远程桌面服务会话标识符。要指示当前会话,请指定WTS_CURRENT_SESSION。你可以使用 WTSEnumerateSessions函数检索指定 RD 会话主机服务器上所有会话的标识符。
//要在另一个用户的会话中打开虚拟频道,需要获得虚拟频道的许可。有关详细信息,请参阅 远程桌面服务权限。要修改会话权限,请使用远程桌面服务配置管理工具。
{[in]}pVirtualName: PAnsiChar//指向包含虚拟通道名称的空终止字符串的指针。请注意,即使定义了 UNICODE,这也是一个 ANSI 字符串。虚拟频道名称由一到 CHANNEL_NAME_LEN 个字符组成,不包括终止null。
): THANDLE; stdcall;

如果函数成功,返回值是指定虚拟通道的句柄。如果函数失败,则返回值为NULL。要获取扩展的错误信息,请调用 GetLastError。

我们的代码如下(注意:我们的客户端存在多个通道,这里我们只打开第一个):

const
pszChannelName: PAnsiChar = 'MyTest1';//对应客户端的虚拟通道名称

procedure TFrmMain.Button1Click(Sender: TObject);
var
dwError: DWORD;
begin
m_hChannelHandle := WTSVirtualChannelOpen(
WTS_CURRENT_SERVER_HANDLE,
WTS_CURRENT_SESSION,
pszChannelName);

if m_hChannelHandle = 0 then
begin
dwError := GetLastError;
ShowMessage(Format('WTSVirtualChannelOpen Error!Code:%d;%s', [dwError, SysErrorMessage(dwError)]));
Exit;
end;
ShowMessage('WTSVirtualChannelOpen OK!');
end;

通道打开成功后,就可以收发数据了。其中发送数据的API原型为:

function WTSVirtualChannelWrite(
{[in]}hChannelHandle: THANDLE;//处理由WTSVirtualChannelOpen函数打开的虚拟通道 。
{[in]}Buffer: PAnsiChar;//指向包含要写入虚拟通道的数据的缓冲区的指针。
{[in]}Length: ULONG;//指定要写入的数据的大小(以字节为单位)。
{[out]}pBytesWritten: PULONG//指向接收写入字节数的变量的指针。
): BOOL; stdcall;

如果函数成功,则返回值为非零值。如果函数失败,则返回值为零。要获取扩展的错误信息,请调用 GetLastError。
注意:WTSVirtualChannelWrite不是线程安全的。要从多个线程访问虚拟通道,或通过虚拟通道执行异步 IO,请将WTSVirtualChannelQuery与 WTSVirtualFileHandle一起使用。具体例子请参阅MSDN的函数WTSVirtualChannelQuery里面的说明,或者参考后面动态通道例子代码。

我们的代码:

procedure TFrmMain.Button3Click(Sender: TObject);
var
szBuffer: array[0..1023] of AnsiChar;
ulBytesWritten: ULONG;
dwError: DWORD;
begin
lstrcpyA(szBuffer, PAnsiChar(AnsiString(Edit1.Text)));
if WTSVirtualChannelWrite(m_hChannelHandle, szBuffer, lstrlenA(szBuffer), @ulBytesWritten) then
begin
ShowMessage('WTSVirtualChannelWrite OK!');
end
else
begin
ShowMessage(Format('WTSVirtualChannelWrite Error!Code:%d;%s', [dwError, SysErrorMessage(dwError)]));
end;
end;

数据接收函数:

function WTSVirtualChannelRead(
{[in]}hChannelHandle: THANDLE;//处理由WTSVirtualChannelOpen函数打开的虚拟通道 。
{[in]}TimeOut: ULONG;//指定超时时间,以毫秒为单位。如果TimeOut为零, 则 WTSVirtualChannelRead在没有要读取的数据时立即返回。如果TimeOut是 INFINITE,函数将无限期等待,直到有数据要读取。
{[out]}Buffer: PAnsiChar;//指向接收从虚拟通道的服务器端读取的数据块的缓冲区的指针。
{[in]}BufferSize: ULONG;//指定Buffer的大小(以字节为单位)。如果Buffer中的数据块前面是CHANNEL_PDU_HEADER结构,则此参数的值应至少为 CHANNEL_PDU_LENGTH。否则,此参数的值应至少为CHANNEL_CHUNK_LENGTH。
{[out]}pBytesRead: PULONG//指向接收读取字节数的变量的指针。
): BOOL; stdcall;

其中第三个参数Buffer,需要注意的地方是:服务器在单个 WTSVirtualChannelRead调用中可以接收的最大数据量是 CHANNEL_CHUNK_LENGTH字节。如果客户端的 VirtualChannelWrite调用写入了更大的数据块,则服务器必须进行多次WTSVirtualChannelRead调用。另外, 在某些情况下,远程桌面服务 在WTSVirtualChannelRead函数读取的每个数据块的开头 放置一个
CHANNEL_PDU_HEADER结构。如果客户端 DLL在调用VirtualChannelInit函数初始化虚拟通道时设置了CHANNEL_OPTION_SHOW_PROTOCOL选项, 就会发生这种情况。如果通道是使用IWTSVirtualChannel::Write方法写入的动态虚拟通道,也会发生这种情况。否则,缓冲区仅接收在 VirtualChannelWrite调用中写入的数据。

如果函数成功,则返回值为非零值。如果函数失败,则返回值为零。要获取扩展的错误信息,请调用 GetLastError。
注意:WTSVirtualChannelRead不是线程安全的。要从多个线程访问虚拟通道,或通过虚拟通道执行异步 IO, 请将WTSVirtualChannelQuery与 WTSVirtualFileHandle一起使用。具体例子请参阅MSDN的函数WTSVirtualChannelQuery里面的说明,或者参考后面动态通道的例子代码。

我们的代码:

procedure TFrmMain.Button4Click(Sender: TObject);
var
szBuffer: array[0..1023] of AnsiChar;
ulBytesRead: ULONG;
dwError: DWORD;
begin
FillChar(szBuffer, sizeof(szBuffer), 0);
if (not WTSVirtualChannelRead(m_hChannelHandle,
1000 * 5, //INFINITE,
szBuffer,
sizeof(szBuffer),
@ulBytesRead)) then
begin
ShowMessage(Format('WTSVirtualChannelRead Error!Code:%d;%s', [dwError, SysErrorMessage(dwError)]));
end
else
begin
Memo1.Lines.Add(StrPas(szBuffer));
end;
end;

最后,在结束通道之前可以使用函数关闭通道,函数原型为:

function WTSVirtualChannelClose(
{[in]}hChannelHandle: THANDLE//处理由WTSVirtualChannelOpen函数打开的虚拟通道 。
): BOOL; stdcall;

如果函数成功,则返回值为非零值。如果函数失败,则返回值为零。要获取扩展的错误信息,请调用 GetLastError。

我们的代码:

procedure TFrmMain.Button2Click(Sender: TObject);
var
dwError: DWORD;
begin
if (not WTSVirtualChannelClose(m_hChannelHandle)) then
begin
dwError := GetLastError;
ShowMessage(Format('WTSVirtualChannelClose Error!Code:%d;%s', [dwError, SysErrorMessage(dwError)]));
Exit;
end;
m_hChannelHandle := 0;
ShowMessage('WTSVirtualChannelClose OK!');
end;

二、动态通道(DynamicVirtualChannels)

动态虚拟通道 (DVC) API 扩展了用于远程桌面服务的现有虚拟通道 API,称为静态虚拟通道 (SVC) API。DVC API 解决了客户端和服务器之间的 SVC API 中存在的几个限制,例如:频道数量有限、数据包重建等。另外,如果想在使用RDPClient Activex的程序内调用虚拟通道,也必须写成动态虚拟通道形式。

(一)客户端

和静态通道一样,目标DLL的位数也必须与操作系统的一致,否则远程桌面客户端不会加载它。另外,动态通道内部是通过COM形式实现的。

1、客户端DLL文件的注册

如果是使用RDPClient Activex的程序,则无需在注册表写入条目,而是调用远程桌面协议 (RDP) ActiveX 控件的IMsTscAdvancedSettings::put_PluginDlls方法。多个条目必须以逗号分隔。

如果是远程桌面客户端使用,则需要将插件条目写入启动远程桌面连接 (RDC) 客户端进程的计算机上的注册表中的以下位置:

HKEY_CURRENT_USER\Software\Microsoft\Terminal Server Client\Default\AddIns\唯一插件名称

在唯一插件名称下,你必须添加一个标识插件的条目。

条目名称 = Name
数据类型 = REG_SZ或REG_EXPAND_SZ

其中,条目值可以分为三种情况:

a、Plug-inDLLName:{CLSID}。例如:“C:\DVCClient.dll:{A2E28376-69FC-4705-ACB6-0923794C0AA2}”。
这种情况下,插件不一定作为组件对象模型 (COM) 对象在 Windows 注册表中注册,但 DLL 是作为进程内 COM 对象实现的。RDC 客户端将加载由Plug-inDLLName指定的 DLL,并直接使用CLSID检索 COM 对象。

b、Plug-inDLLName。例如:“C:\DVCClient.dll”。
这种情况下,DLL 实现VirtualChannelGetInstance函数并按名称导出它。RDC 客户端将使用VirtualChannelGetInstance函数为DLL 实现的所有插件获取IWTSPlugin接口指针。

c、{CLSID}。例如:“{A2E28376-69FC-4705-ACB6-0923794C0AA2}”。
这种情况下,RDC 客户端将使用带有CLSID的CoCreateInstance将插件实例化为常规 COM 对象。

Plug-inDLLName表示 .dll 文件的完整路径和文件名。如果数据类型为REG_EXPAND_SZ,则路径可以包含在运行时展开的未展开环境变量。

当远程桌面连接 (RDC) 客户端完成初始化时,它将为每个已注册的插件执行以下操作:

(1)使用上述方法之一为每个插件获取IWTSPlugin接口的实例。
(2)调用每个IWTSPlugin接口的Initialize方法。
(3)如果客户端多次连接到相同或不同的服务器,则可能会多次调用Connected和Disconnected方法。
(4)插件应处理的最后一个调用是Terminated。这是远程桌面连接 (RDC) 客户端即将卸载插件的信号。

下面是一个从注册表导出的例子:

Windows Registry Editor Version 5.00

[HKEY_CURRENT_USER\SOFTWARE\Microsoft\Terminal Server Client\Default\AddIns\DVCClient]
“Name”=”C:\\DVCClient.dll:{A2E28376-69FC-4705-ACB6-0923794C0AA2}”

2、客户端DLL的编写

2.1 初始化

为了满足上面任意一种的注册形式,我们的DLL将导出多个函数。和静态通道一样,我们也将使用一个VCL窗口,项目代码如下:

library DVCClient;

uses
Windows,
ComServ,
DVCClientUnit in 'DVCClientUnit.pas',
DVCClientInterface in 'DVCClientInterface.pas',
Unit_FrmMain in 'Unit_FrmMain.pas' {FrmMain};

exports
VirtualChannelGetInstance,
DllGetClassObject,
DllCanUnloadNow,
DllRegisterServer,
DllUnregisterServer;

{$R *.RES}

begin
DllProc := @DllMain;
DllMain(DLL_PROCESS_ATTACH);
IsMultiThread := True;
end.

注意:如果注册表指向是仅为{CLSID},则编译DLL后需要以管理员身份调用系统的regsvr32对DLL进行注册一次。

为了实现动态通道,我们必须继承并实现三个接口:

第一个接口IWTSPlugin声明如下:

type
IWTSPlugin = interface(IUnknown)
['{A1230201-1439-4e62-a414-190d0ac3d40e}']
function Initialize_({In}pChannelMgr: IWTSVirtualChannelManager): HResult; stdcall;//用于从客户端到插件的第一次调用。
function Connected(): HResult; stdcall;//通知插件远程桌面连接 (RDC) 客户端已成功连接到远程桌面会话主机(RD 会话主机)服务器。
function Disconnected(dwDisconnectCode: DWORD): HResult; stdcall;//通知插件远程桌面连接 (RDC) 客户端已与远程桌面会话主机(RD 会话主机)服务器断开连接。
function Terminated: HResult; stdcall;//通知插件远程桌面连接 (RDC) 客户端已终止。
end;

其中:

Initialize_方法:参数pChannelMgr,将实例传递给客户端的通道管理器 ( IWTSVirtualChannelManager )。如果调用成功完成,则返回S_OK 。如果调用失败,插件将由远程桌面连接 (RDC) 客户端释放。
Connected方法:如果调用成功完成,则返回S_OK 。如果调用失败返回E_FAIL,但插件将继续工作。
Disconnected方法:参数dwDisconnectCode标识断开连接原因的代码。有关可能的代码,请参阅IMsTscAxEvents::OnDisconnected。如果调用成功完成,则返回S_OK 。如果调用失败,则不执行任何操作。
Terminated方法:通知插件远程桌面连接 (RDC) 客户端已终止。在调用IWTSPlugin::Terminated之后,预计不会再调用该插件。任何插件清理都应该在这里完成。如果调用成功完成,则返回S_OK 。如果调用失败,则不执行任何操作。
作为 COM 对象,插件必须在自由线程模型(free-threading mode)中实现。因为IWTSPlugin方法是由插件实现的,所以插件必须知道调用可能到达不同的线程。调用总是串行到达,因此不可能有任何两个并行执行的调用。
IWTSPlugin接口由 %System32%\webauthn.dll 实现,以启用远程桌面 WebAuthn 重定向功能。通过调用VirtualChannelGetInstance获取该接口的一个实例,它也是由 webauthn.dll 提供的。

第二个接口如下:

type
IWTSListenerCallback = interface(IUnknown)
['{A1230203-d6a7-11d8-b9fd-000bdbd1f198}']
function OnNewChannelConnection(
const {In}pChannel: IWTSVirtualChannel;
{in}data: BSTR;
{out}pbAccept: PBOOL;
{out}out ppCallback: IWTSVirtualChannelCallback
): HResult; stdcall;//允许远程桌面连接 (RDC) 客户端插件接受或拒绝传入连接的连接请求。
end;

这个接口主要用于通知远程桌面连接 (RDC) 客户端插件有关特定侦听器的传入请求,只有一个方法,参数说明如下:

pChannel:表示传入连接的IWTSVirtualChannel对象。仅当此方法接受连接时,才会连接此对象。
data:此参数未实现,保留供将来使用
pbAccept:指示是否应接受连接。如果应该接受连接则 pbAccept设置为TRUE ,否则设置为FALSE。
ppCallback:接收 IWTSVirtualChannelCallback对象,该对象接收连接通知。该对象由插件创建。

如果此方法成功,则返回S_OK。否则,它返回一个HRESULT错误代码。

第三个接口如下:

type
IWTSVirtualChannelCallback = interface(IUnknown)
['{A1230204-d6a7-11d8-b9fd-000bdbd1f198}']
function OnDataReceived(
{In}cbSize: ULONG;
{in}pBuffer: PBYTE
): HResult; stdcall;//通知用户有关正在接收的数据。
function OnClose: HResult; stdcall;//通知用户通道已关闭。
end;

这个接口主要用于接收有关通道状态更改或接收到的数据的通知。该接口由用户实现。此接口的每个实例都与IWTSVirtualChannel的一个实例相关联。此接口的实现不应阻止这些调用,因为这可能会抑制其他回调。不能保证这些调用总是到达同一个线程,即使对于插件的进程内 COM 实现也是如此。在这些回调中允许调用IWTSVirtualChannel的Write和Close方法。

其中:
OnDataReceived方法的参数:
cbSize:接收数据的缓冲区的大小(以字节为单位)。
pBuffer:指向接收数据的缓冲区的指针。此缓冲区仅在此调用完成之前有效。
成功返回S_OK。如果调用失败,则不执行任何操作。

OnClose方法:通知用户通道已关闭。关闭通道的方式有以下三种:

1、用户调用了 IWTSVirtualChannel::Close方法。
2、远程桌面连接 (RDC) 客户端已与远程桌面会话主机(RD 会话主机)服务器断开连接。
3、服务器已调用通道上的 WTSVirtualChannel::Close方法。

无论通道如何关闭,收到此调用时都无需调用 IWTSVirtualChannel::Close()。如果进行了这样的调用,那么如果插件正在用完进程,那么对 IWTSVirtualChannel::Close()的调用
可能会导致死锁。可能会发生死锁,因为 OnClose()的调用者持有频道列表锁,而Close()方法将尝试在不同的线程上获取相同的锁。

可能大家对这几个接口一时之间觉得很懵逼,其实简单来说,就是三步:

第一步:远程桌面客户端初始化插件,调用插件TTDVCClient接口的Initialize_方法。这个时候,插件自己创建一个TWTSListenerCallback的实例a,并调用传递进来的pChannelMgr
接口注册这个实例a。

第二步:当服务端插件运行并打开这个通道的时候,会触发第一步创建的TWTSListenerCallback实例a里面的OnNewChannelConnection事件。这个时候,插件自己创建一个
TWTSVirtualChannelCallback的实例b,将参数pChannel给它保存,然后将b赋值给返回参数ppCallback。

第三步:这个时候,如果需要发送数据,则可以直接调用第二步保存的pChannel的Write方法。同时,如果收到数据,会触发第二步创建的实例b的OnDataReceived函数。

下面的代码例子来自于MSDN:

第一步:

function TTDVCClient.Initialize_(
pChannelMgr: IWTSVirtualChannelManager): HResult;
var
hr: HRESULT;
pListenerCallback: TWTSListenerCallback;
ptrListener: IWTSListener;
begin
FrmMain.ShowInfo('TTDVCClient.Initialize_');

pListenerCallback := TWTSListenerCallback.Create;
hr := pChannelMgr.CreateListener(
'DVC_Sample',
0,
pListenerCallback,
ptrListener);
Result := hr;
end;

其中,IWTSVirtualChannelManager接口声明如下:

IWTSVirtualChannelManager = interface(IUnknown)
['{A1230205-d6a7-11d8-b9fd-000bdbd1f198}']
function CreateListener(
{In}pszChannelName: PAnsiChar;//侦听器将侦听的端点名称。这是一个字符串值,其长度限制为MAX_PATH个字符。
{In}uFlags: ULONG;//该参数是保留的,必须设置为零。
{In}pListenerCallback: IWTSListenerCallback;//返回将接收传入连接通知的侦听器回调 ( IWTSListenerCallback )。
{Out}out ppListener: IWTSListener//IWTSListener对象的实例。
): HResult; stdcall;
end;

第二步:

function TWTSListenerCallback.OnNewChannelConnection(
const pChannel: IWTSVirtualChannel; data: BSTR; pbAccept: PBOOL;
out ppCallback: IWTSVirtualChannelCallback): HResult;
var
hr: HRESULT;
pCallback: TWTSVirtualChannelCallback;
begin
FrmMain.ShowInfo('TWTSListenerCallback.OnNewChannelConnection');

pbAccept^ := FALSE;
pCallback := TWTSVirtualChannelCallback.Create;
pCallback.SetChannel(pChannel);
ppCallback := pCallback;
pChannel._AddRef;
pbAccept^ := TRUE;
Result := S_OK;
end;

第三步:

function TWTSVirtualChannelCallback.OnDataReceived(cbSize: ULONG;
pBuffer: PBYTE): HResult;
begin
FrmMain.ShowInfo('TWTSVirtualChannelCallback.OnDataReceived');
Result := m_ptrChannel.Write(cbSize, pBuffer, nil);//将收到的数据返回给服务端
end;

其中,IWTSVirtualChannel接口声明如下:

IWTSVirtualChannel = interface(IUnknown)
['{A1230207-d6a7-11d8-b9fd-000bdbd1f198}']
function Write(
{In}cbSize: ULONG;//要写入的缓冲区的大小(以字节为单位)。
{In}pBuffer:PByte;//指向要写入数据的通道上的缓冲区的指针。你可以在调用返回后立即重用此缓冲区。
{In}pReserved: IUnknown//保留以供将来使用。该值必须为NULL。
): HResult; stdcall;
function Close(): HResult; stdcall;//关闭通道。
end;

方法Write:在通道上启动写入请求。所有写入都被视为异步。调用此方法复制pBuffer的内容并立即返回,因此可以回收缓冲区。由于内存复制,过多的Write()调用可能会导致客户端分配过多的内存。此通道上的Close()调用将取消任何挂起的写入。函数如果成功则返回S_OK 。
方法Close:如果通道尚未关闭,则Close()方法将调用IWTSVirtualChannelCallback::OnClose()方法进入关联的虚拟通道回调接口。通道关闭后,对其的任何Write()调用
都将失败。函数如果成功则返回S_OK 。

3、服务端插件程序编写

客户端插件DLL编译、安装完毕后(需要注意远程客户端的位数,如果是64位系统,必须编译为64位的DLL),我们来编写服务端插件。服务端插件可以是用户应用层EXE形式,也可以是服务程序等,在客户端连接后,即可进行操作。
这个例子来自于MSDN,是一个控制台形式的EXE,运行后,将打开连接客户端通道,同时创建一个读线程和一个写线程。

3.1 打开/连接客户端虚拟通道
打开客户端的虚拟通道使用函数WTSVirtualChannelOpenEx,函数原型为:

function WTSVirtualChannelOpenEx(
{IN}SessionId: DWORD;//远程桌面服务会话标识符。要指示当前会话,请指定 WTS_CURRENT_SESSION。你可以使用 WTSEnumerateSessions函数检索指定 RD 会话主机服务器上所有会话的标识符。
//要能够在其他用户的会话中打开虚拟频道,你必须具有虚拟频道权限。有关详细信息,请参阅 远程桌面服务权限。要修改会话权限,请使用远程桌面服务配置管理工具。
{_In_}pVirtualName: PAnsiChar;//在 SVC 的情况下,指向包含虚拟通道名称的以 null 结尾的字符串。SVC 名称的长度限制为CHANNEL_NAME_LEN个字符,不包括终止空值。
//在 DVC 的情况下,指向包含侦听器端点名称的以 null 结尾的字符串。DVC 名称的长度限制为MAX_PATH个字符。
{IN}flags: DWORD//要将通道作为 SVC 打开,请为此参数指定0。要将频道作为 DVC 打开,请指定 WTS_CHANNEL_OPTION_DYNAMIC。
): THANDLE; stdcall;

最后一个参数详解:打开 DVC 时,你可以通过指定WTS_CHANNEL_OPTION_DYNAMIC_PRI_XXX值之一以及WTS_CHANNEL_OPTION_DYNAMIC值来为正在传输的数据指定优先级设置。
WTS_CHANNEL_OPTION_DYNAMIC_NO_COMPRESS:禁用此 DVC 的压缩。你必须结合 WTS_CHANNEL_OPTION_DYNAMIC值指定此值。
WTS_CHANNEL_OPTION_DYNAMIC_PRI_LOW(默认):低优先级。数据将以低优先级在两侧发送。将此优先级用于所有大小的块传输,其中传输速度并不重要。在几乎所有 (95%) 的情况下,应该使用此标志打开通道。
WTS_CHANNEL_OPTION_DYNAMIC_PRI_MED:中等优先级。使用此优先级发送必须优先于低优先级通道中的数据的短控制消息。
WTS_CHANNEL_OPTION_DYNAMIC_PRI_HIGH:高优先级。对关键且直接影响用户体验的数据使用此优先级。传输大小可能会有所不同。显示数据属于此类。
WTS_CHANNEL_OPTION_DYNAMIC_PRI_REAL:实时优先级。仅在数据传输绝对关键的情况下使用此优先级。数据传输大小应限制为每条消息几百个字节。

此 API 支持静态虚拟通道 (SVC) 和动态虚拟通道 (DVC) 创建。如果 flags参数为零,则其行为与 WTSVirtualChannelOpen相同。可以通过指定适当的标志来打开 DVC。创建 DVC 后,可以使用与 SVC 相同的函数来读取、写入、查询或关闭。
如果函数成功,返回值是指定虚拟通道的句柄。如果函数失败,则返回值为NULL。要获取扩展的错误信息,请调用 GetLastError。

3.2 获取虚拟通道信息
函数原型为:

function WTSVirtualChannelQuery(
{IN}hChannelHandle: THANDLE;//处理由WTSVirtualChannelOpen函数打开的虚拟通道 。
{IN}unnamedParam2: WTS_VIRTUAL_CLASS;//
{OUT}ppBuffer: PPointer;//指向接收请求信息的缓冲区的指针。
{OUT}pBytesReturned: PDWORD//指向接收ppBuffer 参数中返回的字节数的变量的指针。
): BOOL; 

如果函数成功,则返回值为非零值。使用ppBuffer参数中返回的值调用 WTSFreeMemory函数以释放WTSVirtualChannelQuery分配的临时内存 。如果函数失败,则返回值为零。要获取扩展的错误信息,请调用 GetLastError。

在这个例子里面,显示了如何获得对可用于异步 I/O 的虚拟通道文件句柄的访问权限。首先,代码通过调用 WTSVirtualChannelOpen函数打开一个虚拟通道。然后代码调用 WTSVirtualChannelQuery函数,
指定 WTSVirtualFileHandle 虚拟类类型。 WTSVirtualChannelQuery返回一个文件句柄,你可以使用它来执行异步(重叠)读写操作。最后,代码通过调用 WTSFreeMemory 函数释放 WTSVirtualChannelQuery
分配的内存, 并通过调用 WTSVirtualChannelClose函数关闭虚拟通道 。
请注意,你不应显式关闭通过调用 WTSVirtualChannelQuery获得的文件句柄。这是因为 WTSVirtualChannelClose关闭了文件句柄。

其它详见代码:http://www.138soft.com/download/RDPVirtualChannels.zip。

]]> http://www.138soft.com/?feed=rss2&p=609 0 关于Windows解锁的那些事 http://www.138soft.com/?p=585 http://www.138soft.com/?p=585#respond Mon, 08 Nov 2021 08:30:38 +0000 http://www.138soft.com/?p=585 这里说的解锁,意思是从Windows锁定桌面返回到用户桌面。普通用户怎么解锁呢?很简单,按下热键CTRL+ALT+DEL,出现密码输入框,输入正确的密码,然后回车或点击登录即可。对于程序员来说,如何通过程序实现这个功能呢?

一、发送 CTRL+ALT+DEL 热键

1、Windows 2000、Windows XP、Windows 2003:

 

procedure MySendHotKey;
const
    WM_HOTKEY = $0312;
var
    HDesk_WL: HDESK;
begin
    HDesk_WL := OpenDesktop('Winlogon', 0, False, DESKTOP_JOURNALPLAYBACK);
    if (HDesk_WL <>0) then
    begin
       if (SetThreadDesktop(HDesk_WL) = True) then
      begin
         PostMessage(HWND_BROADCAST, WM_HOTKEY, 0, MAKELONG(MOD_ALT or MOD_CONTROL, VK_DELETE));
         CloseDesktop(HDesk_WL);
      end;
    end;
end;

原理就是切换到winlogon桌面然后模拟键盘热键,所以这个函数必须拥有system权限(比如说服务程序)才能执行成功。

2、Windows Vista及更高版本:

调用sas.dll里面的函数SendSAS(注意:这个Dll是Windows 7后才存在,早期的系统比如说Windows Vista需要自己从Windows7等高版本系统拷贝该Dll过去)。



procedure SendSAS( AsUser:BOOL );stdcall;external 'sas.dll';


这个函数有一个参数,意思是调用程序是何权限(user或system)。如果是user权限,还需要满足以下条件:

a、该程序拥有合法的数字签名。

b、该进程位于可信目录下(例如:Windows目录或Program Files目录)

另外,不管是何种权限,还需要修改组策略:

在HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System下新建一个REG_DWORD类型的名称为SoftwareSASGeneration的Item,值设为1(system)或2(user)。

这个函数本质其实是一个RPC调用,如果不想调用该Dll,也可以自己实现RPC客户端或间接调用:



   function WmsgSendMessage(theSession : dword; theMagic : dword; thePid : dword; theRet : LPARAM):dword;stdcall;external ‘WMsgAPI.dll’; 


其中第一个参数是会话ID,第二个参数是$0208,第三个参数是当前的进程ID。

二、获取已登录用户密码

直接搜索lsass.exe的内存。点这里下载源码(来源:看雪论坛)。注意:对于Windows 8及后面的系统需要修改注册表并重启后才有效。

以前有一个产品,外表是一个USB的U盘,当插入到电脑后,会自动解锁并返回到桌面,很神奇。原理其实是一个badusb,先模拟键盘呼出“讲述人”的驱动安装,然后安装驱动后,在驱动里面注入,获取密码、输入密码并回车。

三、无密码直接解锁已登录桌面

1、 Windows 2000、Windows XP、Windows 2003

注入winlogon.exe,先用OpenDesktop打开默认桌面(‘Default’),再SwitchDesktop()切换即可。对,就是这么简单,连密码都省了。

2、 Windows Vista及更高版本:

a、RPC大法。

b、注入winlogon,获取窗口“’AUTHUI.DLL: LogonUI Logon Window’”的句柄,然后ShowWindow(xx,SW_HIDE);超级简单吧。点这里原理。

]]>
http://www.138soft.com/?feed=rss2&p=585 0
也谈自己实现菜单 http://www.138soft.com/?p=539 http://www.138soft.com/?p=539#comments Sun, 19 May 2019 21:07:36 +0000 http://www.138soft.com/?p=539 Windows的菜单我们每天都在使用,一般情况下,如果需要美化菜单,例如改变字体、背景颜色等,都是直接在菜单的AdvancedDrawItem事件里面进行自绘即可。但如果想实现一些特殊效果,比如说菜单半透明,就的自己实现整个菜单了。

实现一个菜单大概需要处理这些问题:(1)绘制各个菜单项。(2)菜单窗口弹出来后,所属窗口不应该失去焦点。(3)点击了菜单项后应该关闭菜单。对于问题1,我们可以简单的根据菜单项的数量,然后判断状态(比如说鼠标现在是否位于该项目上面)进行绘制;对于问题3,需要判断鼠标点击事件。对于问题2,我们可以类似实现自定义Combobox一样,响应弹出窗口的WM_ACTIVATE消息:

procedure TFrmMenu.WMActivate(var Message: TWMActivate);
begin
  inherited;
  if Message.Active = Integer(False) then
  begin
    if Visible then
    begin
      Close;
    end;
  end
  else
  begin
    SendMessage(FrmParent.Handle, WM_NCACTIVATE, 1, 0);
  end;
end;

就这样,我们第一个版本出炉了。点击这里下载源码。

如果我们仔细观察,会发现这个菜单跟系统的菜单还是有区别的,点击按钮弹出我们的菜单,然后在窗口标题栏右键弹出系统菜单来对比,会发现以下的不同(弹出我们的菜单后,不要关闭):(1)移动鼠标到其它控件(比如说Panel),会触发该控件的鼠标进入CM_MOUSEEnter和鼠标离开CM_MOUSELEAVE消息。移动到窗口标题按钮也是一样(触发NC消息)。(2)移动鼠标到输入框,比如说TMemo上,会触发对方的WM_SETCURSOR消息,从而改变鼠标形状。怎么办呢?通过搜索,找到这篇文章《DirectUI中模态对话框和菜单的原理(自己控制整个Windows消息循环。或者,用菜单模拟窗体打开时用SetCapture取得控制权,一旦窗体收到WM_CAPTURECHANGED消息就把窗体退出)》
这篇文章介绍的是SDK方式的C代码实现代码,但是后面有一句:“我的做法是菜单模拟窗体打开时用SetCapture取得控制权,一旦窗体收到WM_CAPTURECHANGED消息就把窗体退出。”,于是我们简单修改一下,便有了第二个版本。点击这里下载源码。

我们再仔细观察,模态窗口解决了版本1的问题(1)和问题(2)—实际上问题1只解决了一半,如果移动鼠标到窗口标题按钮上,还是会触发消息的。最重要的是,版本二带来了一个新问题:弹出菜单后,不要关闭,直接点击其它控件(比如说按钮1或标题栏最大化按钮),发现没有触发点击事件,而是菜单关闭而已。于是我们再搜索,又发现一篇跟前面的文章差不多的另外一篇:新版MenuDemo——使用Duilib模拟Windows原生菜单于是我们把它翻译成Delphi,并完善了一下(比如说该VC版本的源码没有处理WM_MOUSEACTIVATE消息,在菜单关闭后所属窗口会因为获得焦点闪烁一下)。第三个版本出炉:点击这里下载源码。

通过运行第三个版本,我们发现好像又回到了版本1:(1)移动鼠标到窗口标题按钮(触发NC消息)。(2)移动鼠标到输入框,比如说TMemo上,会触发对方的WM_SETCURSOR消息,从而改变鼠标形状。

抽完一支烟后,我们决定在版本三的基础上,加上类似版本二的模态窗口功能,但又要解决版本二的问题:(1)在消息循环开始前,我们使用API SetCapture(m_hWindow);来实现类似的模态窗口,在菜单关闭后使用ReleaseCapture;还原。(2)对于NC消息和客户区点击消息,我们在消息循环里面转发:

原文的代码:


      else if (msg.message = WM_LBUTTONDOWN)
        or (msg.message = WM_RBUTTONDOWN)
        or (msg.message = WM_NCLBUTTONDOWN)
        or (msg.message = WM_NCRBUTTONDOWN)
        or (msg.message = WM_LBUTTONDBLCLK) then
      begin
        //click on other window
        if not IsMenuWnd(msg.hwnd) then
        begin
          DestroyMenu;

          //为了和菜单再次的弹出消息同步
          PostMessage(msg.hwnd, msg.message, msg.wParam, msg.lParam);
          bInterceptOther := True;
          bMenuDestroyed := True;
        end;
      end

修改后的代码:

      else if (msg.message = WM_LBUTTONDOWN)
        or (msg.message = WM_RBUTTONDOWN)
        or (msg.message = WM_NCLBUTTONDOWN)
        or (msg.message = WM_NCRBUTTONDOWN)
        or (msg.message = WM_LBUTTONDBLCLK) then
      begin
        h := WindowFromPoint(Mouse.CursorPos); //判断鼠标点击的窗口
        if h &amp;lt;&amp;gt; m_hWindow then
        begin
          DestroyMenu;
          msg.hwnd := h;

          if h = m_hWndOwner then //点击的菜单所属窗口
          begin
            {
            判断点击的是什么地方,用于发送WM_MOUSEMOVE,从而触发WM_SETCURSOR。

            可以作如下测试:

                 菜单弹出后,不要关闭,然后鼠标移动到四个角上面,或者边框上面,
            点下鼠标按钮不要松开,这时候,菜单关闭,然后鼠标形状变成窗口可改变
            大小形状,移动鼠标即可改变窗口大小。

            如果不处理,则会出现鼠标形状改变,但拖动鼠标不会改变大小(可以注释掉
            下面的
            SendMessage(m_hWndOwner, WM_SETCURSOR, m_hWndOwner, MakeLong(xRet, WM_MOUSEMOVE));
            来对比效果。
            }
            xRet := SendMessage(m_hWndOwner, WM_NCHITTEST, 0, MakeLong(Mouse.CursorPos.X, Mouse.CursorPos.Y));
            if xRet &amp;lt;&amp;gt; HTCLIENT then
            begin
              SendMessage(m_hWndOwner, WM_SETCURSOR, m_hWndOwner, MakeLong(xRet, WM_MOUSEMOVE)); //边框鼠标

              //下面两个消息是为了响应菜单未关闭时点击标题栏按钮
              case msg.message of
                WM_LBUTTONDOWN: msg.message := WM_NCLBUTTONDOWN;
                WM_RBUTTONDOWN: msg.message := WM_NCRBUTTONDOWN;
              end;
              msg.wParam := xRet;
              msg.lParam := MakeLong(Mouse.CursorPos.X, Mouse.CursorPos.Y);
            end;
          end;

          //可能点击的是窗口上面的按钮
          PostMessage(msg.hwnd, msg.message, msg.wParam, msg.lParam);

          if IsMenuWnd(msg.hwnd) then nRet := m_nSelectIndex;

          DestroyMenu;
          PostMessage(msg.hwnd, msg.message, msg.wParam, msg.lParam);

          bInterceptOther := True;
          bMenuDestroyed := True;
        end;

      end

点击这里下载源码。

现在看起来,我们的菜单好像跟操作系统的一模一样了。但是,实际上,如果再仔细观察,菜单弹出后,直接双击窗口标题栏来最大化窗口,或双击窗口左上角图标来关闭窗口,没有达到预期效果。当然,迅雷和QQ之类的自定义菜单也是不够“完美”的,最多也就是版本三左右的效果:

Windows XP Professional-2019-04-06-12-50-56

估计原理都是跟上面引用的文章一样。但是也不排除人家想要的就是这种所谓不完美的效果。

如何解决这个问题呢?从现象上看,应该是跟鼠标双击消息有关。我们是否类似上面单击一样,把消息WM_LBUTTONDBLCLK转化为WM_NCLBUTTONDBLCLK即可解决问题?回头看看上面的代码:

      else if (msg.message = WM_LBUTTONDOWN)
        or (msg.message = WM_RBUTTONDOWN)
        or (msg.message = WM_NCLBUTTONDOWN)
        or (msg.message = WM_NCRBUTTONDOWN)
        or (msg.message = WM_LBUTTONDBLCLK) then
      begin
        if (msg.message = WM_LBUTTONDBLCLK) then ShowMessage('触发了鼠标双击事件!');//加上这一句
        ......
      end

原来我们实在是太天真了,这个消息压根就没触发。网上搜索,找到这篇文章:Windows如何区分鼠标双击和两次单击,上面说:“两次单击会产生四个鼠标点击消息,如果第三个消息(第二次按下)和第二个消息(第一次弹起引发的WM_LBUTTONUP)间隔短于指定值,则把第三个消息处理成WM_LBUTTONDBLCLK消息;第四个消息照旧,WM_LBUTTONUP。”。通过打印消息,发现我们的程序的确触发了两次WM_LBUTTONDOWN,而且时间间隔、点击坐标都符合条件。拿出Spy++,我们对比一下弹出系统标题栏菜单后直接双击标题栏,和弹出我们的菜单后直接双击标题栏,通过消息对比,发现收到的消息类型、顺序都是一模一样的,但系统的,第二个WM_LBUTTONDOWN后,会产生一个WM_LBUTTONDBLCLK,而我们的自定义菜单就是不会出现这个消息。

现在我们只能猜想,有可能是操作系统还有什么消息发送给了菜单窗口,但Spy++没有截获?最后,我们再回到第三个版本,把代码进一步精简,就是只处理我们需要的消息即可。关键代码是:

//if PeekMessage(msg, 0, 0, 0, PM_REMOVE) then
 if PeekMessage(msg, 0, 0, 0, PM_NOYIELD or PM_NOREMOVE) then//不删除,下面判断是属于需要处理的,我们才从消息队列删除并处理

最终,得到完美的第五个版本。点击这里下载。

]]>
http://www.138soft.com/?feed=rss2&p=539 2
关于一些往事 http://www.138soft.com/?p=527 http://www.138soft.com/?p=527#comments Mon, 25 Jun 2018 04:04:57 +0000 http://www.138soft.com/?p=527         我有一个网站专门是面向国外销售一些代码的,链接是http://www.woods8.com。前段时间,有个自称学生的网友加我的QQ,叫我便宜点卖一个LSP的代码给他,我拒绝了,并且想起了一桩陈年往事。

        刚毕业的那年,这个网站还是叫“藏鲸阁”,为了生活,里面有一些代码在出售,就是上面放了可执行的演示程序并标明价格。其中一个代码叫“极速屏幕传输”,售价是500元人民币,包括代码和开发文档,早期的木马“灰鸽子”是其中一个用户。有一天,一个江苏的网友自称自己是学生(后来发现实际上是一个公司的员工),在做毕业设计,叫我打折卖一个给他。于是我就以300元的价格卖了一个给他,同时赠送了一个老外的同类代码给他参考。结果呢?他说赠送的那个代码已经足够他的应用,叫我退款给他,这个我肯定是不答应的,因为我赠送给你的前提是购买了我的代码,而且退一万步来说,假如一个代码解决了你的需求,即使这个代码本身是免费的,它也值这个信息费用。于是他就开始在大富翁论坛之类开始抹黑我,说我拿老外免费的代码出售,让他“气血沸腾”。

        类似的事情我遇到过不止一次。当年“黑洞远程控制软件”试过推出VIP版本,每年年费是150元,运营了一年后,觉得免杀太繁琐,就停止运营了,对于已经交费的用户,提供了两个二选一的选择:1、不退年费,赠送一个带中转服务器的企业版。2、年费原封不动返回,相当于白玩了一年。结果有一个用户,先是选择了1,等到他拿到了企业版,再选择2。这个我们肯定是不同意的,于是他就开始到处发帖,什么“藏鲸阁xx不讲信誉”。---没错,互联网发个贴成本太低太低。

        自此之后,我就不再在国内出售任何代码了,改到国外。至于代码为什么要收费?因为我一直坚持任何一个东西都有它的价值,因为有人为它付出过汗水。所以多年来我用的软件都是正版的,下面就是当年在亚马逊购买的纪录(家里电脑一个、公司电脑一个、虚拟机里面装几个),点击图片可以查看原图:

无标题1

无标题2

无标题3

        最后,转帖一篇文章,供所有技术人员共勉:

        《技术不养老》

        你二十岁时候,看到有人说做技术不养老,很容易被淘汰,你不服,你觉得被淘汰的都是弱鸡懒虫菜鸟,你看看人家LINUS,看看行痴,看看ROB PICKE。最不济了,看看知乎轮子哥,看看陈皓,看看asta谢。于是你埋头996希望早日技术飞跃,写出自己的牛逼哄哄的开源库,被谷歌收购被阿帕奇投资被腾讯买断。然而到了29你并没有。
        你三十岁时候看到IBM裁员40岁,看到华为劝退45,看到中兴45跳楼,你有些慌了,但是看看身边的老婆孩子,房贷账单,你决心一定更加努力在40岁之前做到总监,做到架构师,做到CTO,毕竟不是很多教程都说三年从小白到架构师吗?然而你到了39的时候,项目EOL撤了,你连tech lead 的职位都保不住了,鉴于你多年付出,公司给你两个选择,要不拿个40万的包走人,要不在公司里面挑一个基层岗位继续做技术。你尝试着出去找了找工作,被一群比你小十岁的娃们用你听都没听过的东西鄙视了一圈,就完全宛如你当年用node.js鄙视jquery,用redis鄙视数据库直接访问,用docker鄙视那些手动配置的老家伙们一样。于是你决定还是留下来凑活干吧。
        你45岁被公司裁掉之后,一个开公司的老同学愿意赏你一口饭吃,考虑到自己被裁之后几个月没找到工作,钱也花的差不多了,你决定去试试,入职几个月就被当年老同学当孩子一样劈头盖脸骂了几顿,最严重一次还是当着办公室里比你小二十岁的小孩面。想想转眼你就50熬退休了,你暂时忍了。后来你终于托人跳到了一家创业公司,CEO比你儿子大五岁。虽然每天12点多才下班,你时不时心慌气短担心自己熬不过去了,不过CEO还算尊重你,薪水能过得下去,你也就熬着了。一个新来的小伙叫你X哥,你劝他们要早做打算。小伙子们表面上认真听着,其实内心和当年的你一样,除了可怜你,更多的是看不起你。
        “30岁没混成管理,35岁没当上总监的都是废物,做技术就应该踏踏实实做技术,公司有技术专门的上升渠道,经验丰富的技术专家非常宝贵稀缺”,这些种种话语一代又一代人的传递着,推动代代人拼搏努力进而搭建起来了世界科技的发展进步。
        我爷爷八十多头脑还清醒的时候就说过,人民大会堂是他盖的,对√他就是当时备受大家尊重的建筑工人,为社会主义添砖加瓦的建筑工人。
        我大舅也经常会说起他当年在纺织三厂做小组长的经历,包括他们下班后的足球比赛和小组当年对纺织机的技术创新如何拿到了国家奖项。对,就是被下岗的我那个大舅。
        听不听随你们,反正一代一代的说了其实也是白说。

]]>
http://www.138soft.com/?feed=rss2&p=527 15
关于Winsock LSP、SPI、NSP、网游加速器那些事 http://www.138soft.com/?p=501 http://www.138soft.com/?p=501#comments Wed, 16 Aug 2017 13:53:49 +0000 http://www.138soft.com/?p=501         最近VPN都被封了,无法使用Google,只好自己动手,丰衣足食了。

        一个是基于ifslsp的,支持TCP重定向:DeProxifier1.0

        另外一个是基于nonifslsp的,支持TCP和UDP重定向:DeProxyCap1.0

        有需要的朋友请点击上面的链接下载使用吧。嗯,跟本站所有软件一样,是完全免费的。当然,这种软件杀毒软件是百分之百报毒的,介意的朋友就不要用了。

        关于LSP,也不打算多做介绍了,请自行搜索吧。如果你正在开发LSP和NSP,那么可以往下看看,我会说说里面的几个要点。如果你没有这方面的基础,看了也是没有用的。

一、关于TCP重定向的实现。

        TCP重定向的实现方法有很多,有些方法是先强制把原来的Socket改为阻塞模型(例如:对于消息模型的,先调用WSPAsyncSelect(s, hWnd, 0, 0, err)取消消息映射;对于事件模型的,先调用WSPEventSelect(s, 0, 0, err)取消事件),跟代理服务器握手成功后,再恢复原来的模型;有些方法是使用状态机;而最简单和最流行的方法是:把连接重定向到127.0.0.1本地自己程序监听的端口,由该程序来完成握手。之所以需要考虑这些问题,是因为当connect函数返回后,原来的程序便开始收发数据了,而LSP层还在和代理服务器协商,因为使用的是同一个socket句柄,所以这两个过程的数据收发是可能导致数据混乱的。
        目前基本上所有LSP程序都是使用最后一个方法,因为还有个原因:对于网络游戏,如果你重定向到真正代理服务器,这个过程是可能消耗一定时间的,可能会导致网络游戏认为连接超时而断开连接,把连接重定向到127.0.0.1就没事了。

二、关于UDP重定向的实现。

        UDP的重定向不像TCP仅处理WSPConnect和WSPConnectEX即可,它涉及到数据的收发,对应的两个函数是:WSPSendto和WSPRecvFrom,我们需要在发送的时候将真正的地址加到原数据头,然后将发送地址改为代理服务器的;在接收返回后,从头数据取出真正服务器的IP并去掉该头数据。我们先来看看,如果使用API HOOK是如何处理接收函数的:

        如果需要截获实际接收的数据的话,单纯的通过WSPRecvFrom这个函数是很片面的,需要分几种情况说明:
        1、如果lpOverlapped为nil,那么是阻塞式的,直接在原函数返回后处理即可。
        2、如果lpOverlapped不为nil,且lpCompletionRoutine不为nil,那么需要使用自己的函数替换lpCompletionRoutine;并在调用后执行调用用户原函数。因为这时是完成例程通知模型。
        3、如果lpOverlapped不为nil,且lpCompletionRoutine为nil,并且lpOverlapped的hEvent不为nil,那么需要hook WSAGetOverlappedResult。因为这时是事件通知模型。
        4、如果lpOverlapped不为nil,且lpCompletionRoutine为nil,并且lpOverlapped的hEvent为nil,那么需要hook GetQueuedCompletionStatus,因为这时是完成端口的方式。

        现在的问题在于,LSP并不提供类似GetQueuedCompletionStatus这种API函数的处理,所以如果仅使用LSP,而且是ifslsp模型的话,那么对于非阻塞模型的UDP都是无法解决的事情。幸好微软还提供了一个nonifslsp的框架,里面自带了一个透明代理层。

        这里额外说说一些常识(摘录自网上):“LSP分两种:一种是IFS LSP,一种是non IFS LSP.简单地说, IFS LSP制作简单,可以完成大部分的数据包监听工作; non IFS LSP制作复杂,但是可以进行一些特殊的overlapped I/O操作,如在overlapped初始化完成后,调用WSPSend (WriteFile), WSPSendTo, WSPRecv (ReadFile), WSPRecvFrom, or WSPIoctl之前,对数据进行一些处理工作.LSP相互之间可以叠加,但在non IFS LSP之上不可以叠加IFS LSP.也就是说,如果一个BSP是non IFS,则第三方提供的LSP必须是non IFS,否则无法安装在SPI上.”当然,世事无完美,nonifslsp也存在一些兼容性问题,例如会导致SetFileCompletionNotificationModes函数运行不正确。详见微软介绍:https://support.microsoft.com/en-hk/help/2568167/setfilecompletionnotificationmodes-api-causes-an-i-o-completion-port-n

三、关于NSP重定向的实现。

        NSP重定向DNS虽然都是在NSPLookupServiceNext里面返回域名对应的IP地址,但是实际上也有很多方法。一种是使用UDP自己构造DNS请求包返回IP,然后替换。这个比较消耗时间。另外一种是远程连接模式,例如:当应用程序解释域名www.138soft.com的时候,NSPLookupServiceNext返回127.8.0.0(这个IP是递归的),同时在内存里面添加一个对应的映射记录:www.138soft.com:127.8.0.0,当应用程序连接到127.8.0.0时,127.8.0.0这个连接与远程代理服务器协商,告诉对方连接到www.138soft.com(代理服务器基本上都支持IP重定向和域名重定向),这时候使用的实际上就是代理服务器那边的DNS了。

]]>
http://www.138soft.com/?feed=rss2&p=501 4
AQ虚拟化办公系统 http://www.138soft.com/?p=471 http://www.138soft.com/?p=471#comments Fri, 03 Mar 2017 07:50:33 +0000 http://www.138soft.com/?p=471         以前练习界面开发时的一个副产品,因为一直没有时间更新和维护,为了避免浪费,就放出来给感兴趣的朋友使用。

        演示录像地址为:AQ云办公系统演示录像

        本地软件下载地址列表:因为服务器安装程序使用了VMP加壳(http://www.vmpsoft.com/),所以有可能杀毒软件会误报,如果介意请不要下载。下面几个连接的内容都是一样的,不同的是压缩包格式和是否加有压缩密码(防止误杀)。

A、AQ虚拟化办公系统(解压密码123).rar
Size: 66547523 bytes
MD5: DFDE8DD1C292E87FCA226F88C0AA7F60
SHA1: B2777B7191AE1A8935B9D71BF5FADFAF426E4601
CRC32: 29B16FC1

B、AQ虚拟化办公系统(解压密码123).zip
Size: 66890341 bytes
MD5: E8D745DB693FCD7DEC663D8F4890DA69
SHA1: D65F477D9880A05304C4A97BF4092CED3E0958C2
CRC32: 4AEF5913

C、AQ虚拟化办公系统.rar
Size: 66547399 bytes
MD5: E71EF47176FE4F508D1B57AC63DD3B7A
SHA1: D0F1E5F74428FFF8A6AE0C326B2775260022FB8E
CRC32: 01BD60AB

D、AQ虚拟化办公系统.zip
Size: 66890089 bytes
MD5: 077819EB126E7950FBAE5B819F62A93A
SHA1: 3F28328A2C32DEF6604E11F11E562B6BD0D4FF8C
CRC32: 0E4D6FCA

非凡软件站下载链接:http://www.crsky.com/soft/119071.html


        

AQ虚拟化办公系统功能概述

        AQ虚拟化办公系统是一个基于云端的办公系统,本系统具有以下功能和特点:

1、 办公软件运行于云端,数据不在本机落地,有效的保护了企业资料外泄。

        用户在服务器安装需要用到的办公软件后,根据需要将软件分配给对应的用户,用户端登录平台后,在列表中点击该软件图标,则软件在服务器运行,并将软件界面传输到客户端显示,客户端对该界面的操作将发送到服务器,从而实现软件虚拟化。
由于软件运行于服务器,该软件(例如office word)最终生成的一切数据将保存在服务器,避免了数据被拷贝的可能。

2、 办公软件安装在云端,有效的降低了企业的运营成本。

        一些企业本身拥有大量旧电脑,因为性能跟不上新软件,面临淘汰的局面。使用本系统后,因为软件运行在服务器,客户端电脑性能要求将大大降低(只需要能够显示图片即可),这些旧电脑将起死回生,为企业节约了大量的硬件成本。现在总理鼓励大学生创业,对于初创业者,成本也是第一个要面临的问题。例如一个图像工作室,处理图像的电脑对显卡性能要求非常高,一台一般的图形处理电脑就需要几万块。使用本系统后,只需要购买性能好的服务器,然后客户端可以使用淘宝上卖的一体机(例如占美一体机,售价是500元),甚至购买一些二手电脑,成本降低了,但效果不改变。

        使用本系统还可以有效的降低软件成本,有利于软件正版化。例如一套正版的Office软件售价大约是人民币5千元,一个公司如果有30个员工,每个员工电脑安装一套,就是人民币15万。使用本系统后,只需要购买一台安装在服务器上,成本马上减少14.5万。又例如,某连锁药店用的收银系统每个点是人民币5万,茂名市下属各个乡镇有30多家分店,使用本系统后,马上节约140多万人民币。(注意:本系统并不对软件进行破解,因为正版软件一般是通过检测机器硬件特征码或加密狗来判断是否合法,本系统类似于:购买一个点,安装在一台电脑,然后在电脑前面放一个摄像头,其他人通过远程视频和电话来使用该软件)

3、 办公软件统一部署在云端,有效的降低了企业的管理成本。

        因为所有软件统一安装在服务器,当需要进行软件升级或更新时,只需要更新服务器一台电脑即可,而无须各个员工电脑分别更新,节约了大量工作时间。

        另外,服务器可以安装在局域网内,配置双网卡,一个网卡连接内网路由,一个网卡连接外网路由。员工电脑全部仅连接内网路由,然后服务器根据需要分配浏览器等需要连接外网的程序给用户,还可以设置时间(例如:中午12:00~14:00才运行运行IE浏览器),方便了企业网管的管理。

4、 办公软件运行于云端,方便以后的无设备办公。

        以后的企业,或许将不再提供电脑给员工,而是由员工自带IPAD等设备上班,因为系统仅传输图片,所以在IOS和Andriod等操作系统上也可以使用Windows平台的软件(毕竟目前的办公软件还是基于Windows系统的)。

5、 本系统可以运行于局域网,也可以运行于互联网,方便远程办公。

6、 企业信息安全,岂可假手他人?

        本系统内置了IM功能,既方便了企业的团队化工作。同时也有效的减少了员工上班时间跟外界闲聊的可能性。最重要的是,因为使用的是自己的服务器,即使聊天内容涉及到本企业的商业秘密,也不会有第三方知道。(现在市面上的公共IM,所有聊天记录都会经过其服务器进行敏感词汇检查;为了让你可以在手机端查看原来在电脑端的聊天,所有的聊天记录都会保存在其服务器。)
对于有需要的用户,我们还可以集成企业现有的OA等系统,实现单点登录。

系统技术指标:
1、 安全性。本系统叫AQ,意为“安全”的拼音缩写。本系统所有的通讯数据均使用网银的SSL级别加密―――例如IM功能,市面上的IM一般只是文字聊天进行了简单加密,图片传输、文件传输等都是明文的,而本系统是所有数据都加密。服务器云端用户则使用了操作系统本身的NT级别的用户加密。这是微软操作系统级别的加密,经过多年的验证,具体请参考微软的安全白皮书。
2、 友好性。操作上完全傻瓜化,无须增加额外的培训和学习成本。
3、 其它技术参数:
(1) 通讯协议:TCP+UDPP2P。云办公功能所需带宽:28.8KB。
(2) 语音功能:GIPS引擎。
(3) 视频传输:X264+D3D加速。

2015年6月8日

]]>
http://www.138soft.com/?feed=rss2&p=471 12
Winsock异步转换域名到IP的一个需要注意的地方 http://www.138soft.com/?p=434 http://www.138soft.com/?p=434#comments Sat, 06 Dec 2014 15:03:16 +0000 http://www.138soft.com/?p=434         转换域名到IP是网络编程中使用的很频繁的功能,一般情况下gethostbyname即可,但这个函数是阻塞的,如果你的网络程序框架是基于异步的(例如消息socket、完成端口等),那么可能需要一个异步的函数。微软本身提供了一个异步的函数叫WSAAsyncGetHostByName,调用的时候,需要提供一个窗口的句柄和消息定义,当异步返回的时候,会触发该窗口的消息。问题在于,这个函数是顺序的。举个例子:你按顺序查询www.163.com、www.sina.com……,假如前面的163没有返回,实际上后面的sina也需要等待。也就是说,它其实是按顺序查询的。或许针对每个查询新建一个窗口来接收消息可以避免这个问题,感兴趣的朋友可以尝试一下。另外一个解决方案是自己构造DNS查询包来实现异步查询功能。

]]>
http://www.138soft.com/?feed=rss2&p=434 4
WinPcap权威指南(四):UDP与DNS欺骗 http://www.138soft.com/?p=420 http://www.138soft.com/?p=420#comments Thu, 07 Aug 2014 07:41:10 +0000 http://www.138soft.com/?p=420         上一节介绍了ARP协议之后,群里面有朋友说ARP欺骗不是很简单么?实际上,实现ARP欺骗是很简单的,难点在于数据转发的速度。2004年我们做隔山打牛的时候,曾经去过一家网吧测试,刚开始是开了ARP欺骗几分钟就大量主机掉线,客人骂声一片(幸好是朋友的网吧,否则估计会被老板丢出去)。当然,那时候网上还没有ARP欺骗的介绍,更加没有什么ARP防火墙,ARP欺骗的防范是几年后的事情了。后来我们改进了算法,经过实际测试,250台电脑,原来的ICMP速度是多少(ping),开启ARP欺骗后就是多少,没有任何延迟,机器更加不会掉线。因为我们的代码仅出于演示目的,所以捕获线程和分析线程共用了一个List,然后加锁,如果你直接拿去网吧用,百分之百是被网吧老板抓住狂揍一顿的。

        群里还有朋友说cain一开,整个局域网会掉线,这个实际上是cain处理的不好:它启动ARP欺骗后,并没有对现有连接的seq和ack等做转换,另外还有一种解决方法是被动欺骗。不过实际上,数据修改并不一定要上ARP欺骗这种中间人方式,不使用ARP欺骗而达到数据修改,至少有三种方法,后面我们会介绍一两种,至于第三种,因为命中率百分之百,为了造成不良后果,以后有机会再说。

        上一节我们说了,网络通信的第一步是通过ARP协议获取目标电脑的物理地址—局域网内通信是获取目标电脑的,互联网是获取网关的,那第二步呢?答案当然是IP地址。所有基于IP的网络协议(例如icmp、tcp、udp)都必须拥有这个,但是因为ip难以记忆,而且有可能改变,所有实际应用中一般使用域名协议。例如,我们打开cmd,然后ping 一下我们的网站www.138soft.com,结果如下图:

xwinpcap8

        从上图可以看到,ICMP协议会先获取www.138soft.com对应的ip地址,然后再跟它收发数据,用过浏览器的朋友们,实际上当你们输入域名的时候,浏览器也会有这个过程。写过网络程序的朋友就清楚的多,因为这个就是gethostbyname函数。这个过程,实质上就是DNS协议。

        DNS协议的规范定义这里不作介绍了,我们只从程序的角度来说一下。DNS协议一般使用UDP,而且端口一般是53。DNS数据包分为查询包和应答包,其中查询包包含了这个包的ID(用于区别其它的查询包)、包的类型(这里为查询)、查询的类型(比如说:IP地址是A记录、MX发信地址是MX记录),DNS服务器的反馈包结构,前面是这个请求包(但包类型修改为反馈包),后面紧跟着具体的应答包。下面我们通过自己写的一个程序来重现这个过程(实际上,这个就是系统的gethostbyname函数的实现)。首先,我们使用iphlp库来获取本机的DNS服务器IP:

function GetDNSServerIP: AnsiString;
var
  dwBuffSize: Cardinal;
  pFinxedInfo: PFIXED_INFO;
  pIPAddr: PIP_ADDR_STRING;
begin
  Result := '';

  if (GetNetworkParams(nil, dwBuffSize) <> ERROR_BUFFER_OVERFLOW) then Exit;
  GetMem(pFinxedInfo, dwBuffSize);
  if pFinxedInfo = nil then Exit;
  if (GetNetworkParams(pFinxedInfo, dwBuffSize) <> ERROR_SUCCESS) then
  begin
    FreeMem(pFinxedInfo);
    Exit;
  end;
  pIPAddr := @pFinxedInfo^.DnsServerList;
  while (pIPAddr <> nil) do
  begin
    if Result = '' then Result := Format('%s', [pIPAddr^.IpAddress.S]);
    pIPAddr := pIPAddr^.Next;
  end;
  FreeMem(pFinxedInfo);
end;

        有个DNS地址,就可以构造请求包发送和接收了:

procedure TForm1.Button1Click(Sender: TObject);
//这里省略部分内部函数,具体看代码,函数来源于Indy
var
  AUDPSocket: TSocket;
  SockAddrIn: TSockAddrIn;
  strHostName: AnsiString;
  nDNSPort: Integer;
  QueryID: Word;
var
  nLen, nRet: Integer;
begin
  (Sender as TButton).Enabled := False;
  try
    if Trim(Edit_Host.Text) = '' then
    begin
      ShowMessage('请输入域名!');
      Edit_Host.SetFocus;
      Exit;
    end;

    if Trim(Edit_DNSServer.Text) = '' then
    begin
      ShowMessage('请输入DNS服务器IP!');
      Edit_DNSServer.SetFocus;
      Exit;
    end;

    if Trim(Edit_DNSPort.Text) = '' then
    begin
      ShowMessage('请输入DNS服务器端口!');
      Edit_DNSPort.SetFocus;
      Exit;
    end;

    strHostName := AnsiString(Trim(Edit_Host.Text));

    nDNSPort := StrToIntDef(Trim(Edit_DNSPort.Text), 53);

    AUDPSocket := socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    if AUDPSocket = INVALID_SOCKET then Exit;
    SockAddrIn.sin_family := AF_INET;
    SockAddrIn.sin_port := htons(nDNSPort);
    SockAddrIn.sin_addr.s_addr := inet_addr(PAnsiChar(AnsiString(Trim(Edit_DNSServer.Text))));


    Randomize;
    QueryID := Random(65535);
    Buffer.Count := 0;
    Buffer.ID := Swap(QueryID); //查询的ID
    Move(Header_Option[1], Buffer.Option, 10);
    StringToLabel(strHostName, SubLabel);
    Move(SubLabel[1], Buffer.Data[0], Length(SubLabel));
    Buffer.Count := Length(SubLabel);
    QType := #0#1; //A记录
    Move(QType[1], Buffer.Data[Buffer.Count], 2);
    Buffer.Count := Buffer.Count + 2;
    Move(QClass_Internet, Buffer.Data[Buffer.Count], 2);
    Buffer.Count := Buffer.Count + 14; // 2 + 12

    if sendto(AUDPSocket, PAnsiChar(@Buffer.ID)^, Buffer.Count, 0, SockAddrIn, sizeof(SockAddrIn)) = SOCKET_ERROR then
    begin
      CloseSocket(AUDPSocket);
      ShowMessage('数据发送失败!');
      Exit;
    end;

    nLen := sizeof(SockAddrIn);
    nRet := recvfrom(AUDPSocket, PAnsiChar(@Buffer.ID)^, sizeof(Buffer), 0, SockAddrIn, nLen);
    if nRet = SOCKET_ERROR then
    begin
      CloseSocket(AUDPSocket);
      ShowMessage('数据接收失败!');
      Exit;
    end;
    CloseSocket(AUDPSocket);
    //==========================================================================
    if Buffer.ID = Swap(QueryID) then
    begin
      i := 0;
      while Buffer.Data[i] <> #0 do Inc(i);
      i := i + 5;
      while (Buffer.Data[i] <> #32) and (i < nRet) do
      begin
        LabelToString(@Buffer.Data, i, RRDomain);
        Inc(i);
        RRDataType := Ord(Buffer.Data[i]);
        i := i + 9;
        case RRDataType of
          QType_CNAME: Memo1.Lines.Add(GetCNAME(Buffer, i));
          QType_NS: Memo1.Lines.Add(GetNS(Buffer, i));
          QType_A: Memo1.Lines.Add(GetA(Buffer, i));
          QType_SOA: Memo1.Lines.Add(GetSOA(Buffer, i));
          QType_PTR: Memo1.Lines.Add(GetPTR(Buffer, i));
          QType_MX: Memo1.Lines.Add(GetMX(Buffer, i));
        else
          begin
            uShort.B[1] := Buffer.nData[i - 2];
            uShort.B[0] := Buffer.nData[i - 1];
            i := i + uShort.Value;
          end;
        end;//end for "case"
      end;//end for "while"
    end;//end for "if"
  finally
    (Sender as TButton).Enabled := True;
  end;
end;

        程序运行界面如下:

xwinpcap9

        对应的,我们再来实现一个DNS服务器,代码如下:

const
    HOSTIP='127.0.0.1';//需要返回的IP地址

procedure TForm1.ReadData(var Message: TMessage);
var
  client_addr: TSockAddrIn;
  szBuffer: array[0..4095] of AnsiChar;
  len: integer;
  flen: integer;
  Event: word;
var
  strRecvData: AnsiString;

  ID, Code, QDCount: Word;
  BitCode: TDNSBitCode;
  i: integer;
  APos: Integer;
  DomainName: AnsiString;
  QueryClass: Word;
  QueryType: Word;

  strSend: AnsiString;
begin
  flen := sizeof(client_addr);
  Event := WSAGetSelectEvent(Message.LParam);
  if Event = FD_READ then
  begin
    len := recvfrom(m_UDPSocket, szBuffer, sizeof(szBuffer), 0, client_addr, flen);
    if len <= 0 then Exit;
    if len > 512 then Exit;

    SetLength(strRecvData, len);
    Move(szBuffer[0], strRecvData[1], len);

    if Length(strRecvData) < sizeof(TDNSHeader) then Exit;
    ID := TwoCharToWord(strRecvData[1], strRecvData[2]);
    Code := TwoCharToWord(strRecvData[3], strRecvData[4]);
    BitCode := GetDNSBitCode(Code);
    if BitCode.QR <> 0 then Exit; //0表示查询,1表示反馈
    QDCount := TwoCharToWord(strRecvData[5], strRecvData[6]);
    if QDCount <= 0 then Exit;

    APos := 13; // DNS头恒为12字节,我们从下一字节开始分析
    for i := 1 to QDCount do
    begin
      DomainName := DNSStrToDomain(strRecvData, APos);
      if DomainName = '' then Exit;
      QueryType := TwoCharToWord(strRecvData[APos], strRecvData[APos + 1]);
      Inc(APos, 2);
      QueryClass := TwoCharToWord(strRecvData[APos], strRecvData[APos + 1]);
      Inc(APos, 2);
      Memo1.Lines.Add(DomainName);
      (*
      m_strInfo := 'DNS查询包' + #$D#$A;
      m_strInfo := m_strInfo + SourceIP + ':' + IntToStr(SourcePort) + '--->' + DestIP + ':' + IntToStr(DestPort) + #$D#$A;
      m_strInfo := m_strInfo + 'DNSID:' + IntToStr(ID) + #$D#$A;
      m_strInfo := m_strInfo + 'Domain:' + DomainName + #$D#$A;
      m_strInfo := m_strInfo + 'QueryType:' + GetDNSTypeStr(QueryType) + #$D#$A;
      m_strInfo := m_strInfo + 'QueryClass:' + GetDNSClassStr(QueryClass) + #$D#$A;
      m_strInfo := m_strInfo + '===================================================';
      Synchronize(ShowInfo);
      *)
    end;
    strSend := BuilderDNSResponse(szBuffer, len, HOSTIP);//构造DNS应答包
    sendto(m_UDPSocket, strSend[1], Length(strSend), 0, client_addr, flen);
  end;
end;

        程序写的很简单,就是根据查询包,分析出客户端需要查询的域名,在Memo1显示出来,然后构造一个应答包返回。这里出于演示目的,我们简单的全部返回127.0.0.1,对于真正的DNS服务器,这里会使用gethostbyname之类往上一级查询,或直接在数据库和缓存里面查找,根据不同的域名返回对应的IP。我们先运行程序,然后把另外一台机器的DNS服务器指向这个IP:

xwinpcap10

        然后随便Ping 一下www.qq.com:

xwinpcap11

        可以看到,返回的IP地址是127.0.0.1。同时我们的DNS服务器的界面如下:

xwinpcap12

        最后,我们把DNS服务端的代码移植到我们的Demo代码里面,就实现了DNS欺骗。需要注意的地方是,基于IP的数据包都有一个校验和的,所以如果你修改了数据包,一定要重新计算一次校验和,具体过程见CheckSum函数。程序运行效果如下:

xwinpcap13

        DNS欺骗利用的原理是:DNS客户端发送请求包后,只要返回包里面的ID跟发送的一致,它就认为合法。如果有多个返回包,则它使用最前面的,这个是跟ARP刚好相反的(ARP会使用最新的覆盖原来的)。几年前,360云查杀的时候,我做过一个试验,就是在应用层开启RAWSOCKET来捕获数据,发现是请求360云服务器的话,则直接创建一个UDP SOCKET然后往请求地址发送一个回复,360就无法连接真正的云服务器了。实际上,不只是DNS协议如此,对于TCP协议,也是一样的。只要你捕获到它的数据包,就可以实现网页插代码、下载内容替换等等。这里再回头前面的话题:不用ARP欺骗就修改数据。方法之一就是在路由的镜像口接一台电脑,比如说,想欺骗TCP,只要你回复的数据包的SEQ和ACK按照顺序,客户端就会老老实实的被欺骗。因为你的程序工作在路由旁边,比真正的服务器回复的速度是更快的,甚至你可以再伪造回复的同时,顺便发送一个RST包给真正的服务器,让它断开连接。如果你无法接触路由,又不想使用ARP怎么办?那就用方法二或方法三了。

附件下载:
本节代码

]]>
http://www.138soft.com/?feed=rss2&p=420 6
WinPcap权威指南(三):ARP协议 http://www.138soft.com/?p=409 http://www.138soft.com/?p=409#comments Mon, 04 Aug 2014 09:15:23 +0000 http://www.138soft.com/?p=409         ARP协议在局域网内使用的非常广泛,它的数据包类型分为请求包和答复包。Windows系统内部有一个缓冲区,保存了最近的ARP信息,可以在cmd下使用命令arp -a来显示目前的缓存,或者使用命令arp -d来清除该缓存(Win7下需要以管理员权限运行cmd)。

        在局域网内,两台机器之间通信,实际上靠的是网卡的物理地址。比如说,本机的IP是192.168.1.80,现在想往另外一台IP为192.168.1.138的机器发送一个ICMP包,或者发起一个TCP连接,操作系统第一步会先获取目标机器的网卡物理地址,获取的步骤是先在ARP缓存里面查找,如果没有找到,则发送ARP请求包(一般是广播方式,询问这个IP的网卡物理地址是多少,目标机器收到请求包,发现请求的目标IP是自己,则反馈一个ARP回复包)。如果目标IP的网卡物理地址获取失败,则无法通信—因为不管TCP、UDP还是ICMP,这些基于以太网的数据包,都有一个以太网帧头。如果没有目标的网卡物理地址,底层就无法填充目的地结构,也不知道发送给哪个网卡了。

        下面我们结合程序来做个实验。先说说如何调用WinPcap发送数据包。WinPcap的发包函数是PacketSendPacket,这里我们封装一个函数来调用它:

function SendPackets(pBuffer: PAnsiChar; dwSize: Cardinal; bFree: Boolean): Boolean;
var
  pSendPacket: LPPACKET;
begin
  Result := False;

  pSendPacket := PacketAllocatePacket;
  if pSendPacket = nil then Exit;

  //初始化一个_PACKET结构。
  PacketInitPacket(pSendPacket, pBuffer, dwSize);

  //发送一个或多个数据报的副本。
  if (PacketSendPacket(g_pAdapter, pSendPacket, 1) = 0) then
  begin
    PacketFreePacket(pSendPacket);
    Exit;
  end;

  if bFree then FreeMem(pBuffer);

  PacketFreePacket(pSendPacket);

  Result := True;
end;

        我们先把程序里面TAnalysePacketsThread的ProcessUDPPacket(LPUDPPacket(pBuffer));注释掉,只留下ProcessARPPacket函数,用意其实是我们只处理ARP包,暂时不理会其它类型的。然后打开cmd,执行命令arp -d清除缓冲区。然后输入命令”ping 192.168.1.138″,这时候,可以看到我们的程序捕获到两个ARP包:一个是本机发送的ARP请求包,这是一个目标物理地址为FF:FF:FF:FF:FF的广播包,类型是请求包,内容是询问192.168.1.138的物理地址;另外一个就是对方的回复包了。我们再arp -a显示,发现这个IP对应的物理地址已经在缓冲区里面的(不过ICMP包被对方的防火墙拦截了,所以ping没返回)。

winpcap3
winpcap4

然后我们再连接这个IP,这时候,机器不再发送请求包了(因为缓冲区内还有这个记录)。 另外,我们在目标机器用arp -a命令,可以看到该机器也有发送端的物理地址记录了:
winpcap5

        回到我们程序的主界面,细心的朋友可能发现网关物理地址是空的。实际上,我们的电脑如果和外网通信,比如说连接微软的站点,操作系统发送数据包的时候,是如何获取微软主机的网卡物理地址呢?答案是获取不到,也无须获取。操作系统组包的时候,目标物理地址会填写网关的物理地址,然后将数据包发送出去。网关路由器收到数据包后,发现目的IP地址不是自己,就将源物理地址改成自己的,目标物理地址改成下一级的路由的物理地址,然后发送出去。下一级路由则同样重复这个过程,直到数据到底最终目的地。

        我们先来解决这个界面问题—获取网关的物理地址。方法1是从本机的ARP缓存获取(一般地说,只要连接过外网,缓存肯定有对应的记录),类似cmd下的arp -a命令:

function GetMacAddrInCache(DestIP: DWORD; pMAC: PULONG): Boolean;
var
  dwSize: Cardinal;
  dwRet: Cardinal;
  pIpNetTable: PMIB_IPNETTABLE;
  i: Cardinal;
begin
  Result := False;

  //从本机ARP表中来查询得到
  dwSize := 0;
  dwRet := GetIpNetTable(nil, dwSize, False);
  if (dwRet <> ERROR_INSUFFICIENT_BUFFER) then Exit;

  GetMem(pIpNetTable, dwSize);
  if pIpNetTable = nil then Exit;
  dwRet := GetIpNetTable(pIpNetTable, dwSize, False);
  if (dwRet <> ERROR_SUCCESS) then
  begin
    FreeMem(pIpNetTable);
    Exit;
  end;

  for i := 0 to pIpNetTable^.dwNumEntries - 1 do
  begin
    //pIpNetTable^.table[i].dwIndex;      //适配器索引
    //pIpNetTable^.table[i].dwPhysAddrLen;//物理接口长度
    //pIpNetTable^.table[i].bPhysAddr;    //物理地址
    //pIpNetTable^.table[i].dwAddr;       //IP地址
    //pIpNetTable^.table[i].dwType;       //ARP条目类型
    //MIB_IPNET_TYPE_STATIC;
    if (DestIP = pIpNetTable^.table[i].dwAddr) then //列表中找到该IP信息
    begin
      CopyMemory(pMAC, @pIpNetTable^.table[i].bPhysAddr, 6);
      FreeMem(pIpNetTable);
      Result := True;
      Exit;
    end;
  end;

  if pIpNetTable <> nil then
  begin
    FreeMem(pIpNetTable);
    //pIpNetTable := nil;
  end;
end;

        方法2就是发送请求包,调用了iphlpapi.dll里面的SendARP函数:

function GetMACAddrByARP(DestIP: DWORD; pMAC: PULONG): Boolean;
var
  dwSize: Cardinal;
  dwRet: Cardinal;
begin
  Result := False;
  dwSize := 6;
  dwRet := SendARP(DestIP, 0, pMAC, dwSize);
  if (dwRet = NO_ERROR) and (dwSize <> 0) then
  begin
    Result := True;
    Exit;
  end;
end;

        界面的处理过程:

procedure TFrmMain.ComboBox_GatewayIPsChange(Sender: TObject);
var
  IP: Cardinal;
  Mac: TMacAddr;
begin
  Edit_GatewayMac.Clear;
  if ComboBox_GatewayIPs.Text = '' then Exit;
  IP := inet_addr(PAnsiChar(ComboBox_GatewayIPs.Text));
  if not GetMacAddrInCache(IP, @Mac) then
    if not GetMACAddrByARP(IP, @Mac) then Exit;

  Edit_GatewayMac.Text := Mac2Str(Mac);
end;

        我们前面已经介绍了WinPcap的发包函数,那么我们还可以使用第三种方法,自己构造arp请求包,函数如下:

function SendARPPacket(const DestMac, SourceMac: TMacAddr; const strDestIP, strSourceIP: AnsiString; bReQuest: Boolean): Boolean;
var
  ARPPacket: TARPPacket;
begin
  //1、填充TEthernetHeader
  CopyMemory(@ARPPacket.EthernetHeader.DestMac, @DestMac, sizeof(TMacAddr));
  CopyMemory(@ARPPacket.EthernetHeader.SourceMac, @SourceMac, sizeof(TMacAddr));
  ARPPacket.EthernetHeader.EthernetType := htons(ETHERTYPE_ARP);

  //2、填充TARPHeader
  ARPPacket.ARPHeader.HardwareType := htons(ARP_HARDWARE);
  ARPPacket.ARPHeader.ProtocolType := htons(ETHERTYPE_IP);
  ARPPacket.ARPHeader.HrdAddrlen := 6;
  ARPPacket.ARPHeader.ProAddrLen := 4;
  if bReQuest then ARPPacket.ARPHeader.Operation := htons(ARP_REQUEST)
  else ARPPacket.ARPHeader.Operation := htons(ARP_REPLY);

  CopyMemory(@ARPPacket.ARPHeader.SenderMAC, @SourceMac, sizeof(TMacAddr));
  ARPPacket.ARPHeader.SenderIP := inet_addr(PAnsiChar(strSourceIP));
  CopyMemory(@ARPPacket.ARPHeader.TargetMAC, @DestMac, sizeof(TMacAddr));
  ARPPacket.ARPHeader.TargetIP := inet_addr(PAnsiChar(strDestIP));

  Result := SendPackets(@ARPPacket, sizeof(ARPPacket), False);
end;

        例如,我们需要获取192.168.1.138的物理地址,则这样调用:

procedure TFrmMain.Button1Click(Sender: TObject);
var
  DestMac, SourceMac: TMacAddr;
  strDestIP, strSourceIP: AnsiString;
begin
  FillChar(DestMac, sizeof(DestMac), $FF); //广播包
  SourceMac := Str2Mac(Trim(Edit_OwnMac.Text));
  strSourceIP := Trim(ComboBox_OwnIPs.Text);
  strDestIP := '192.168.1.138';
  SendARPPacket(DestMac, SourceMac, strDestIP, strSourceIP, True);
end;

        然后从程序主界面,或本机的arp -a命令,或目标机器的arp -a命令都可以验证这个过程。现在我们来看看电脑里面这个ARP缓存表是什么时候更新的。实际上,除了上面说的主动请求获取外,只要机器收到ARP包,不管是请求包还是反馈包,它都会添加或更新自己的缓存。修改一下上面的代码,我们广播一条ARP请求包,但是将源IP地址改成一个不存在的IP“192.168.1.111”:

procedure TFrmMain.Button1Click(Sender: TObject);
var
  DestMac, SourceMac: TMacAddr;
  strDestIP, strSourceIP: AnsiString;
begin
  FillChar(DestMac, sizeof(DestMac), $FF); //广播包
  SourceMac := Str2Mac(Trim(Edit_OwnMac.Text));
  strSourceIP := '192.168.1.111';//Trim(ComboBox_OwnIPs.Text);
  strDestIP := '192.168.1.138';
  SendARPPacket(DestMac, SourceMac, strDestIP, strSourceIP, True);
end;

        在目标机器输入arp -a,发现的确出现了一条对应的记录。也就是说,如果现在在目标机器连接“192.168.1.111”,那么实际上会连接我们的机器。

winpcap6

        如果我们把源IP改成跟目标IP一样呢?

procedure TFrmMain.Button1Click(Sender: TObject);
var
  DestMac, SourceMac: TMacAddr;
  strDestIP, strSourceIP: AnsiString;
begin
  FillChar(DestMac, sizeof(DestMac), $FF); //广播包
  SourceMac := Str2Mac(Trim(Edit_OwnMac.Text));
  strSourceIP := '192.168.1.138';//Trim(ComboBox_OwnIPs.Text);
  strDestIP := '192.168.1.138';
  SendARPPacket(DestMac, SourceMac, strDestIP, strSourceIP, True);
end;

        目标机器会出现如下提示框:

winpcap7
        
如果开一个线程重复这个过程,那台机器就无法上网了。

        最后我们讲述一下ARP欺骗和路由器。因为局域网内通信是依靠物理地址,而只要机器收到请求或反馈包,都会更新自己的缓存,所以可以利用这个特性,进行ARP欺骗。具体做法就是:欺骗目标电脑,告诉它网关的IP对应的物理地址是自己;欺骗网关,告诉它目标IP的物理地址是自己。也就是开一个线程不断的发送ARP请求或反馈包给这两个苦主,然后目标电脑发往外网的数据,都会发送到你的电脑,你的电脑收到数据包后,检查如果目标IP不是自己,说明应该转发,你就把该数据包的源物理地址改成自己的,目标物理地址改成网关的,然后发送出去(注意:源IP地址不要改变);外网返回数据后,将数据发给你的电脑,你的电脑收到数据包后,检查如果目标IP(这时候,明白为什么前面说源IP地址不要改变了吧?)不是自己,说明应该转发,你就把该数据包的源物理地址改成自己的,目标物理地址改成目标电脑的,然后发送出去。

        实际上,这个就是路由器的实现,只不过它是把这个代码写进了芯片里面。另外它不用ARP欺骗,而是你自己将网络连接的网关IP设置成它的。你也可以按照上面的流程,去掉ARP欺骗的过程,仅保留数据转发,然后将局域网另外一台机器的网关设置为你的机器,那么它就需要通过你来上网了。因为现在ARP欺骗不像2003年,到处都是代码,这里就不给出具体的实现,留作大家的课后作业吧。

        下一节我们将讲述UDP协议部分,进入初步的数据修改阶段。我们先实现一个应用层的DNS客户端和服务端,再来在驱动层玩玩。

附件下载:
本节代码

]]>
http://www.138soft.com/?feed=rss2&p=409 2
WinPcap权威指南(二) http://www.138soft.com/?p=403 http://www.138soft.com/?p=403#respond Sun, 03 Aug 2014 11:01:47 +0000 http://www.138soft.com/?p=403         上一节我们简单介绍了WinPcap的一些基础知识,同时也枚举到所有网卡的设备名称,现在我们就可以操作网卡了。WinPcap有一个结构叫TADAPTER,你可以把它想象为一个句柄,我们平时操作文件,一般是先打开/创建一个文件,如果成功,则返回一个句柄,然后读文件就可以使用ReadFile,写文件可以使用WriteFile,操作完毕后,CloseHandle关闭这个句柄,WinPcap下的操作也是类似的:

1、打开一个网卡,返回句柄:function PacketOpenAdapter(AdapterName: PAnsiChar): LPADAPTER;

        其中AdapterName就是上一节获取的网卡设备名称,打开成功则返回该网卡的句柄,失败返回nil。一般地说,打开句柄后,还可以做一些初始化工作。例如:调用PacketSetHwFilter将网卡设置为混杂模式(只有网卡处于混杂模式,才可以获取不是发送给本机的数据);调用PacketSetBuff设置缓冲区大小;调用PacketSetReadTimeout设置接收超时。具体请参考演示代码。

2、从网卡获取数据:function PacketReceivePacket(AdapterObject: LPADAPTER; pPacket: LPPACKET; Sync: Byte): Byte;

        AdapterObject: 就是网卡的句柄,pPacket则对应一个TPACKET结构的指针。一般是先通过函数PacketAllocatePacket来分配一个pPacket,再调用PacketInitPacket将自己的接收缓冲区和它关联,使用完毕后调用PacketFreePacket释放掉。对应的WinPcap内部实现如下:


function PacketAllocatePacket(): LPPACKET;
var
  pPacket: LPPACKET;
begin
  pPacket := GlobalAllocPtr(GMEM_MOVEABLE or GMEM_ZEROINIT, sizeof(TPACKET));
  if (pPacket = nil) then
  begin
    //TRACE_PRINT("PacketAllocatePacket: GlobalAlloc Failed");
  end;

  Result := pPacket;
end;

procedure PacketInitPacket(var pPacket: LPPACKET; Buffer: Pointer; Length: UINT);
begin
  //TRACE_ENTER("PacketInitPacket");

  pPacket^.Buffer := Buffer;//关联用户缓冲区
  pPacket^.Length := Length;//用户缓冲区大小
  pPacket^.ulBytesReceived := 0;
  pPacket^.bIoComplete := 0;

  //TRACE_EXIT("PacketInitPacket");
end;

procedure PacketFreePacket(var pPacket: LPPACKET);
begin
  //TRACE_ENTER("PacketFreePacket");
  GlobalFreePtr(pPacket);
  //TRACE_EXIT("PacketFreePacket");
end;

3、通过网卡发送数据:function PacketSendPacket(AdapterObject: LPADAPTER; pPacket: LPPACKET; Sync: Byte): Byte;

4、关闭网卡句柄:procedure PacketCloseAdapter(lpAdapter: LPADAPTER);

        我们的程序使用了两个线程来处理数据,其中TRecvPackThread用于从网卡获取数据,然后添加到全局的g_List;而TAnalysePacketsThread则从g_List里面取出数据来处理。为了界面更友好,再添加一个TTimer来获取WinPcap的内部状态。

        现在我们获取了数据,但这个数据就是一个指针,里面是什么东西呢?其实跟我们的《张曼玉与指针》里面说的,数据就是一切,你说它是什么,它就是什么。实际上,我们捕获的都是以太网帧数据,帧头结构如下:

type
  _ETHERNET_HDR = packed record
    DestMac: array[0..5] of Byte; //目的MAC地址
    SourceMac: array[0..5] of Byte; //源MAC地址
    EthernetType: Word; //帧类型
  end;
  TEthernetHeader = _ETHERNET_HDR;
  LPEthernetHeader = ^_ETHERNET_HDR;

        实际上,不管上层是什么协议,到了底层,都会封装为以太帧,然后发送出去。例如,TCP协议,是基于IP协议的,那么到了底层,实质上会封装为:以太帧头+IP头+TCP头+实际数据(如果存在)。所以我们在这里根据EthernetType判断是什么类型的帧,再作进一步解释:

    pEthernetHeader := LPEthernetHeader(pBuffer);
    case ntohs(pEthernetHeader^.EthernetType) of //判断以太帧类型
      ETHERTYPE_ARP: ProcessARPPacket(LPARPPacket(pBuffer));
      ETHERTYPE_IP: //IP帧
        begin
          pIPHeader := LPIPHeader(pBuffer + sizeof(TEthernetHeader)); //判断是TCP、UDP还是ICMP等
          case pIPHeader^.Protocol of
            IPPROTO_TCP: ProcessTCPPacket(LPTCPPacket(pBuffer));
            IPPROTO_UDP: ProcessUDPPacket(LPUDPPacket(pBuffer));
          end;
        end;
    end;

winpcap2

附件下载:
本节代码

        本节仅简单的解释了ARP和UDP协议,下一节再结合发包深入网络的连接过程。例如:ARP的欺骗、路由器的实质、数据的修改等。

]]>
http://www.138soft.com/?feed=rss2&p=403 0