64位内核开发第一讲,IRP 派遣函数 与 通信。 驱动框架补充
IRP 派遣函数 与通信方式
一丶IRP
1.1 IRP介绍 理论知识
在Windows内核中,有一种数据结构叫做 IRP(I/O Request Package)
也就是输入输出请求包。它是与输入输出相关的重要数据结构
只要了解了IRP 那么驱动开发以及内核你就会了解一大半了。
当上层 应用程序
与驱动程序
进行通讯的时候 应用程序会发出 I/O
请求。操作系统会将请求转为相应的IRP 数据。不同的IRP
数据 会按照类型传递到不同的派遣函数中。
1.2 IRP的类型
当应用层调用 ReadFile WriteFile CreateFile CloseHandle
等WINAPI 函数 则会产生对应的IRP类型的的IRP 也就是 IRP_MJ_CREATE IRP_MJ_WRITE IRP_MJ_READ IRP_MJ_CLOSE
并且传送到驱动的中的派遣函数中。
另外 内核中的 I/O
处理函数也会产生IRP,所以可见IRP并不完全是由应用层产生的。比如内核中的 Zw系列开头的文件操作 一样会产生IRP。
IRP类型 | 来源 |
---|---|
IRP_MJ_CREATE | CreateFile/ZwCreateFile |
IRP_MJ_READ | ReadFile/ZwReadFile |
IRP_MJ_WRITE | WriteFile/ZwWriteFile |
IRP_MJ_CLOSE | CloseHandle/ZwClose |
... | ... |
... | ... |
1.3 派遣函数
当我们知道IRP类型之后只需要给驱动设置派遣函数即可。 这样当应用层调用对应的 Winapi发送IO请求数据包的时候我们的派遣函数则会获取到。
代码如下:
extern "C" NTSTATUS DriverEntry (
IN PDRIVER_OBJECT pDriverObject,
IN PUNICODE_STRING pRegistryPath )
{
NTSTATUS status;
KdPrint(("Enter DriverEntry\n"));
//设置卸载函数
pDriverObject->DriverUnload = HelloDDKUnload;
//设置派遣函数
pDriverObject->MajorFunction[IRP_MJ_CREATE] = HelloDDKDispatchRoutin;
pDriverObject->MajorFunction[IRP_MJ_CLOSE] = HelloDDKDispatchRoutin;
pDriverObject->MajorFunction[IRP_MJ_WRITE] = HelloDDKDispatchRoutin;
pDriverObject->MajorFunction[IRP_MJ_READ] = HelloDDKDispatchRoutin;
pDriverObject->MajorFunction[IRP_MJ_CLEANUP] = HelloDDKDispatchRoutin;
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = HelloDDKDispatchRoutin;
pDriverObject->MajorFunction[IRP_MJ_SET_INFORMATION] = HelloDDKDispatchRoutin;
pDriverObject->MajorFunction[IRP_MJ_SHUTDOWN] = HelloDDKDispatchRoutin;
pDriverObject->MajorFunction[IRP_MJ_SYSTEM_CONTROL] = HelloDDKDispatchRoutin;
//创建驱动设备对象
status = CreateDevice(pDriverObject);
KdPrint(("Leave DriverEntry\n"));
return status;
}
在我们的DriverEntry中 有一个驱动对象参数 其中此参数的 MajorFunction是一个数组。数组里面存放着记录着IRP类型的派遣函数的回调函数指针。所以我们根据如上设置之后。当winapi发送IO请求的时候对应的派遣函数则会调用。
1.4 设备对象 与符号链接
设备对象
也是驱动中的很重要的对象。 我们的IRP是要发送给设备的。所以需要创建设备对象。但是如果应用层想要发送IO请求(调用WINAPI) 那么内核驱动必须提供个符号链接给应用层使用。 内核层创建好设备之后还可以指定通讯方式。 也就是 应用-驱动 如何进行通信。数据如何传输。这个下面再说。
代码如下:
NTSTATUS CreateDevice (
IN PDRIVER_OBJECT pDriverObject)
{
NTSTATUS status;
PDEVICE_OBJECT pDevObj;
PDEVICE_EXTENSION pDevExt;
//创建设备名称
UNICODE_STRING devName;
RtlInitUnicodeString(&devName,L"\\Device\\MyDDKDevice");
//创建设备
status = IoCreateDevice( pDriverObject,
sizeof(DEVICE_EXTENSION),
&(UNICODE_STRING)devName,
FILE_DEVICE_UNKNOWN,
0, TRUE,
&pDevObj );
if (!NT_SUCCESS(status))
return status;
pDevObj->Flags |= DO_BUFFERED_IO; //通信方式设置后面说明。
pDevExt = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;
pDevExt->pDevice = pDevObj;
pDevExt->ustrDeviceName = devName;
//创建符号链接
UNICODE_STRING symLinkName;
RtlInitUnicodeString(&symLinkName,L"\\??\\HelloDDK");
pDevExt->ustrSymLinkName = symLinkName;
status = IoCreateSymbolicLink( &symLinkName,&devName );
if (!NT_SUCCESS(status))
{
IoDeleteDevice( pDevObj );
return status;
}
return STATUS_SUCCESS;
}
1.5 IRP堆栈介绍
IPR堆栈也是很重要的 IO数据包结构。因为IRP结构中记录的数据不足与满足我们的需求。所以提供了IRP堆栈。 比如 应用程序发出的I/O 请求是读的请求,并且此请求会发送到内核的读派遣函数中。 那么此时堆栈就是读的堆栈。 所以类型的不同堆栈被填充的内容也会是不同的。
官方说法是 驱动程序会创建一个设备对象,并且将这些设备对象串联到一起。形成了一个 设备栈
IRP会被操作系统发送到设备栈的顶层,如果顶层设备对象的派遣函数
结束了IRP请求,那么此次的IRP请求就会结束,不会往下发送了。否则操作系统就会将IRP再转发到设备栈的下一层设备进行处理。如果设备依旧不能处理,那么继续往下发。 因此IRP会被转发多次。为了记录IRP在每层设备中的操作,IRP会有一个堆栈数组。IRP的堆栈数组元素数应该大于IRP穿越过的设备数。每个 堆栈结构元素记录着对应设备所作的操作。
上面所述的堆栈数组结构如下:
数组名结构为: IO_STACK_LOCATION
typedef struct _IO_STACK_LOCATION {
UCHAR MajorFunction;
UCHAR MinorFunction;
UCHAR Flags;
UCHAR Control;
union {
//
// Parameters for IRP_MJ_CREATE
//
struct {
PIO_SECURITY_CONTEXT SecurityContext;
ULONG Options;
USHORT POINTER_ALIGNMENT FileAttributes;
USHORT ShareAccess;
ULONG POINTER_ALIGNMENT EaLength;
} Create;
//
// Parameters for IRP_MJ_READ
//
struct {
ULONG Length;
ULONG POINTER_ALIGNMENT Key;
LARGE_INTEGER ByteOffset;
} Read;
//
// Parameters for IRP_MJ_WRITE
//
struct {
ULONG Length;
ULONG POINTER_ALIGNMENT Key;
LARGE_INTEGER ByteOffset;
} Write;
//
// Parameters for IRP_MJ_QUERY_INFORMATION
//
struct {
ULONG Length;
FILE_INFORMATION_CLASS POINTER_ALIGNMENT FileInformationClass;
} QueryFile;
//
// Parameters for IRP_MJ_SET_INFORMATION
//
struct {
ULONG Length;
FILE_INFORMATION_CLASS POINTER_ALIGNMENT FileInformationClass;
PFILE_OBJECT FileObject;
union {
struct {
BOOLEAN ReplaceIfExists;
BOOLEAN AdvanceOnly;
};
ULONG ClusterCount;
HANDLE DeleteHandle;
};
} SetFile;
//
// Parameters for IRP_MJ_QUERY_VOLUME_INFORMATION
//
struct {
ULONG Length;
FS_INFORMATION_CLASS POINTER_ALIGNMENT FsInformationClass;
} QueryVolume;
//
// Parameters for IRP_MJ_DEVICE_CONTROL and IRP_MJ_INTERNAL_DEVICE_CONTROL
//
struct {
ULONG OutputBufferLength;
ULONG POINTER_ALIGNMENT InputBufferLength;
ULONG POINTER_ALIGNMENT IoControlCode;
PVOID Type3InputBuffer;
} DeviceIoControl;
..............................
} Parameters;
PDEVICE_OBJECT DeviceObject;
PFILE_OBJECT FileObject;
.
.
.
} IO_STACK_LOCATION, *PIO_STACK_LOCATION;
在此结构中我们可以看到 IRP类型的记录域
UCHAR MajorFunction;
UCHAR MinorFunction;
也记录着 设备对象 文件对象
PDEVICE_OBJECT DeviceObject;
PFILE_OBJECT FileObject;
其中比较重要的就是 Parameters
参数。它里面记录着 Read Write DeviceIoControl Create
等结构。 当我们 IRP类型为Read的时候。派遣函数 则可以从 Read域中获取读取的长度 偏移等信息。
调用本层堆栈信息 使用的API如下
PIO_STACK_LOCATION
IoGetCurrentIrpStackLocation(
IN PIRP Irp
);
1.6 派遣函数中的IRP处理
在派遣函数中我们可以使用如下API来完成IRP的操作
VOID
IoCompleteRequest(
IN PIRP Irp,
IN CCHAR PriorityBoost
);
此API第一个参数就是派遣函数中给定的IRP
第二个参数我们一般都是设置为 IO_NO_INCREMENT
第二个参数的意思如下:
第二个参数的意思代表优先级,指的是阻塞的线程以何种优先级恢复运行。
原因是如果是 鼠标 键盘等输入设备他们需要更快的反应。所以需要指定优先级 以“优先”的身份运行。
我们常用的就是 IO_NO_INCREMENT
还有其他方式。请查询WDK文档。
二丶内核与应用层的通信方式 缓存方式(缓冲区方式)
2.1 缓存方式
缓存方式 就是 应用层发送数据到内核层中,内核层建立一个缓冲区来进行保存。 而我们操作这个缓冲区即可。 这样的好处是安全 稳定。 缺点是效率慢。
缓存方式 在我们创建完设备对象之后。将设备对象的标志设置为 DO+_BUFFERD_IO
即可。
pDevObj->Flags |= DO_BUFFERED_IO;
如果设置为缓冲区模式。那么我们只需要在 IRP结构
中获取AssociatedIrp.SystemBuffer
即可。
IRP结构如下
typedef struct _IRP {
.
.
PMDL MdlAddress; //直接IO会使用
ULONG Flags;
union {
struct _IRP *MasterIrp;
.
.
PVOID SystemBuffer; //缓冲区模式使用
} AssociatedIrp;
.
.
IO_STATUS_BLOCK IoStatus; //状态
KPROCESSOR_MODE RequestorMode;
BOOLEAN PendingReturned;
.
.
BOOLEAN Cancel;
KIRQL CancelIrql;
PDRIVER_CANCEL CancelRoutine;
PVOID UserBuffer; //其它方式
。。。。。。。
} IRP, *PIRP;
2.2 读取 写入 控制等IRP的大小获取
在我们的派遣函数中如果指定了缓冲区模式。那么我们从IRP中获取 SystemBuffer
使用即可。
但是派遣函数 会根据 IRP不同的类型来分配不同的派遣函数调用。 其中就会有 IRP_MJ_READ IRP_MJ_WRITE IPR_MJ_DEVICECONTROL
根据派遣函数的不同我们获取的用户传递缓冲区方式的大小也是不同的。
比如IRP_MJ_READ
我们要在 IRP堆栈 中的 Parameters.Read.Length
来获取长度
如果是 IRP_MJ_WRITE 那么相应的要在 Write.length 来获取长度
如果是Control中 那么就是 DeviceIoControl中获取。
其中他还比较特殊它的域如下:
struct {
ULONG OutputBufferLength;
ULONG POINTER_ALIGNMENT InputBufferLength;
ULONG POINTER_ALIGNMENT IoControlCode;
PVOID Type3InputBuffer;
} DeviceIoControl;
记录着应用层传递的输出buffer的长度。 输入buffer的长度。 控制码。
以及 使用其它方式通讯类型的 用户区的缓冲区。 后面会说。
2.3 缓存方式派遣函数中的使用例子
NTSTATUS DisPathchRead_SystemBuffer(PDEVICE_OBJECT pDeviceobj, PIRP pIrp)
{
KdBreakPoint();
PVOID pBuffer = NULL;
ULONG uReadLength = 0;
ULONG ustrLen = 0;
PIO_STACK_LOCATION pIrpStack = NULL;
pIrpStack = IoGetCurrentIrpStackLocation(pIrp); //获取堆栈
pBuffer = pIrp->AssociatedIrp.SystemBuffer; //缓存方式获取缓冲区
uReadLength = pIrpStack->Parameters.Read.Length;//根据不同类型在不同域中获取长度
if (pBuffer != NULL && uReadLength > 0)
{
ustrLen = strlen("HelloWorld");
if (uReadLength < ustrLen)
goto END;
RtlCopyMemory(pBuffer, "HelloWorld", ustrLen);
}
END:
pIrp->IoStatus.Information = ustrLen;
pIrp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
三丶MDL方式(直接IO方式)
3.1 直接IO方式
Mdl方式是将用户传递的Buffer进行映射。在内核中映射了一份。这样用户模式和内核模式的缓冲区都是指向了同一块物理内存,无论操作系统如何切换进程内核模式的地址都不会改变。
优点: 速度快 安全稳定。
使用MDL方式首先也是在创建设备之后设置设备通信方式为直接IO方式。
如下:
pDeviceObj->Flags |= DO_DIRECT_IO;
设置之后在IRP域中的 MdlAddress 则记录着映射的地址
3.2Mdl结构
MDL是一个结构,记录着这段虚拟内存(用户的buffer) 。
因为内存是不连续的所以MDL会像链表一样记录
typedef struct _MDL {
struct _MDL *Next; //下一个MDL
CSHORT Size; //记录本身MD
CSHORT MdlFlags;
struct _EPROCESS *Process; //记录当前进程的EP
PVOID MappedSystemVa;//记录内核中的地址
PVOID StartVa; //记录第一个页地址
ULONG ByteCount; //记录虚拟机内存大小
ULONG ByteOffset;//记录相对于页的偏移
} MDL, *PMDL;
MmGetMdlVirtualAddress 返回 MDL 描述的 i/o 缓冲区的虚拟内存地址。
MmGetMdlByteCount 返回 i/o 缓冲区的大小(以字节为单位)。
MmGetMdlByteOffset 返回 i/o 缓冲区开始处的物理页内的偏移量。
MmGetSystemAddressForMdlSafe例程将指定 MDL 描述的物理页面映射到系统地址空间中的虚拟地址
MDL很多,需要详细了解可以看一下微软文档。
这里只说明我们需要使用的。
其中虚拟内存首地址是我们计算出来的 VA = StartVa + ByteOffset
3.3 直接IO通信例子
NTSTATUS DisPathchRead_Mdl(PDEVICE_OBJECT pDeviceobj, PIRP pIrp)
{
KdBreakPoint();
PVOID pBuffer = NULL;
ULONG uReadLength = 0;
ULONG uOffset = 0;
ULONG ustrLen = 0;
PIO_STACK_LOCATION pIrpStack = NULL;
PVOID pKernelbase = NULL;
//获取堆栈,例子中没用。使用的时候需要根据IRP类型获取操作的长度
pIrpStack = IoGetCurrentIrpStackLocation(pIrp);
uReadLength = MmGetMdlByteCount(pIrp->MdlAddress);//获取IO长度(数组的)
uOffset = MmGetMdlByteOffset(pIrp->MdlAddress); //页偏移
pBuffer = MmGetMdlVirtualAddress(pIrp->MdlAddress);//第一个页
//获取内核中映射的地址
pKernelbase = MmGetSystemAddressForMdlSafe(pIrp->MdlAddress, NormalPagePriority);
if (pKernelbase != NULL && uReadLength > 0)
{
ustrLen = strlen("HelloWorld");
if (uReadLength < ustrLen)
goto END;
RtlCopyMemory(pKernelbase, PsGetProcessImageFileName(pIrp->MdlAddress->Process), ustrLen);
}
END:
pIrp->IoStatus.Information = ustrLen;
pIrp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
四丶其它方式读写
4.1 其它方式
其它方式读写则是直接将用户的缓冲区传递给内核。但是这样如果进程发生切换则会造成蓝屏。所以需要我们去判断是否可读。有点效率是最快的。 缺点 不安全。
设置 设备对象中的标志为 0即可
pDeviceObj->Flags = 0;
读写的数据都在 IRP结构中的UserBuffer中。
示例如下:
NTSTATUS DisPathchRead_UserBuffer(PDEVICE_OBJECT pDeviceobj, PIRP pIrp)
{
KdBreakPoint();
PVOID pBuffer = NULL;
ULONG uReadLength = 0;
ULONG uOffset = 0;
ULONG ustrLen = 0;
PIO_STACK_LOCATION pIrpStack = NULL;
PVOID pKernelbase = NULL;
pIrpStack = IoGetCurrentIrpStackLocation(pIrp); //获取堆栈
pBuffer = pIrp->UserBuffer;
uReadLength = pIrpStack->Parameters.Read.Length;//根据IRP类型不同获取不同的长度
if (pBuffer != NULL && uReadLength > 0)
{
ustrLen = strlen("HelloWorld");
if (uReadLength < ustrLen)
goto END;
__try
{
ProbeForRead(pBuffer, 1, 1);//是否可读写。ProbeForWrite
RtlCopyMemory(pKernelbase, PsGetProcessImageFileName(pIrp->MdlAddress->Process), ustrLen);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
.....
}
}
END:
pIrp->IoStatus.Information = ustrLen;
pIrp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}
五丶IO控制设备通讯方式
5.1 DeviceIoControl通讯
在上面我们说了 ReadFile WriteFile
会分别产生 IRP_MJ_READ IRP_MJ_WRITE
且如果我们指定了通讯方式。那么分别就从不同的地方来获取 ring3传递给内核的Buffer.
如下:
Irp->MdlAddress //直接IO DO_DIRECT_IO
Irp->AssociatedIrp.SystemBuffer; //缓冲区方式 DO_BUFFERED_IO
Irp->UserBuffer; //其它方式 0
在Ring3下面我们还可以通过 DeviceIoControl
这个 WinApi来与内核进行通讯
通讯的的前提是我们需要使用 CreateFile
来打开我们内核提供的 符号链接
打开成功后返回 对象句柄
我们的 DeviceIoControl
就可以来通过这个对象句柄
来与内核进行通讯了。
BOOL DeviceIoControl(
[in] HANDLE hDevice,
[in] DWORD dwIoControlCode,
[in, optional] LPVOID lpInBuffer,
[in] DWORD nInBufferSize,
[out, optional] LPVOID lpOutBuffer,
[in] DWORD nOutBufferSize,
[out, optional] LPDWORD lpBytesReturned,
[in, out, optional] LPOVERLAPPED lpOverlapped
);
参数 | 说明 |
---|---|
hDevice | 句柄,由CreateFile打开符号链接后返回。 |
dwIoControlCode | 控制码,下面会详解。 |
lpInBuffer | 传递给内核层的输入缓冲区。内核解析此缓冲区进行操作。 |
nInBuffferSize | 输入缓冲区的大小 |
lpOutBuffer | 传递给内核层的输出缓冲区,内核层将结果写入此缓冲区。 |
nOutBufferSize | 输出缓冲区大小 |
lpBytesReturned | 传递给内核层的4字节变量,来接受返回值的。内核层可设置返回值。一般都是记录 读取/写入 多少字节的。内核层设置。 |
lpOverLapped | 是否允许异步 |
观看参数,其实除了控制码其它都很好理解。 这里不再赘述。
5.2 控制码详解
控制码是一个32位无符号整型
控制码也称为 IOCTL
它需要符合DDK的标准。
32位分别代表了不同信息。图如下:
DDK还为我们提供了一个宏。 只需要使用这个宏进行填充即可。
#define CTL_CODE( DeviceType, Function, Method, Access ) ( \
((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) \
)
我们只需要填充这个宏即可。
那么说一下参数含义
参数 | 说明 |
---|---|
DeviceType | 设备对象的类型,对应内核层中调用IoCreateDevice的时候传递的类型,一般是FILE_DEVICE_XXX |
Function | 驱动程序自定义的IOCTL控制码,0x0000-0x7FFF是微软保留。程序员应该使用0x800-0xFFF |
Method | 与驱动通信的时候操作模式也就是是缓冲区方式 还是MDL方式还是其它方式 |
Access | 权限,一般都是设置位FILE_ANY_ACCESS |
通信操作模式:
-
METHOD_BUFFERED
使用缓冲区方式
-
METHOD_IN_DIRECT
使用直接写模式
-
METHOD_OUT_DIRECT
使用直接读模式
-
METHOD_NEITHER
使用其它方式
5.3 缓冲区方式
当使用 DeviceIoControl
函数,并且通信方式指明为 METHOD_BUFFERED
那么DeviceIoControl
传递的 InBuffer
OutBuffer
都会转化为IRP中的
SystemBuffer 我们直接从 IRP_MJ_DEVICE_CONTROL
指向的派遣函数中拿到
SystemBuffer即可,拿到后直接解析那么就是解析的 Inbuffer
的内容 然后如果需要传出的时候 直接写入 SystemBuffer
即可。传出写入那么就是往 OutBuffer
中写。
总结来说,如果是缓冲区模式,那么输入输出缓冲区都是一个缓冲区,在内核中都会封装到 Irp中的SystemBuffer中。
想要获取读取/写入的字节 那么就要在 Irp堆栈中的Parameters中的 控制码域来得到。
这点与上面几个主题中所讲的一样。不同的控制码要在不同的域中拿到读取或者写入的长度
struct {
ULONG OutputBufferLength;
ULONG POINTER_ALIGNMENT InputBufferLength;
ULONG POINTER_ALIGNMENT IoControlCode;
PVOID Type3InputBuffer;
} DeviceIoControl;
如上 可以拿到 输出缓冲区的长度 输入缓冲区的长度 控制码(派遣函数需要根据控制码来执行不同的操作) Type3InputBuffer后面说。
5.4 直接内存模式
直接内存模式就是 METHOD_IN_DIRECT
和 METHOD_OUT_DIRECT
他们都是直接内存模式。唯一差别就是体现在打开设备的权限上。如果只读方式打开设备(CreateFile) 那么METHOD_IN_DIRECT的 IOCTL则会成功,而METHOD_OUT_DIRECT则会失败。如果读写权限成功 那么都会成功。
经过尝试:
1.METHOD_IN_DIRECT
METHOD_OUT_DIRECT
输入缓冲区都是在 irp->AssociatedIrp.SystemBuffer,输出缓冲区都是 irp->MdlAddress
必须使用API MmGetSystemAddressForMdlSafe
来获取内核中映射的地址。 也就是操作MDL.
5.5 其它内存模式
其它内存模式就是 设置 IOCTL
为 METHOD_NEITHER
这种方式很少用到。因为
他是直接访问用户的地址。使用的时候还必须要保证 线程上下文环境一致。
它的特点如下:
struct {
ULONG OutputBufferLength;
ULONG POINTER_ALIGNMENT InputBufferLength;
ULONG POINTER_ALIGNMENT IoControlCode;
PVOID Type3InputBuffer;
} DeviceIoControl
DeviceIoControl->Type3InputBuffer
记录着输入缓冲区
Irp->UserBuffer
记录着输出缓冲区
其中输入输出缓冲区的长度还是在 DeviceIoControl
记录着。
如果使用用户模式的缓冲区 一定还是要使用 ProbeForRead ProbeForWirte
来校验。
否则分分钟蓝屏。
这种方式优点就是最快 但也是最不安全。
坚持两字,简单,轻便,但是真正的执行起来确实需要很长很长时间.当你把坚持两字当做你要走的路,那么你总会成功. 想学习,有问题请加群.群号:725864912(收费)群名称: 逆向学习小分队 群里有大量学习资源. 以及定期直播答疑.有一个良好的学习氛围. 涉及到外挂反外挂病毒 司法取证加解密 驱动过保护 VT 等技术,期待你的进入。
详情请点击链接查看置顶博客 https://www.cnblogs.com/iBinary/p/7572603.html
本文来自博客园,作者:iBinary,未经允许禁止转载 转载前可联系本人.对于爬虫人员来说如果发现保留起诉权力.https://www.cnblogs.com/iBinary/p/15838812.html
欢迎大家关注我的微信公众号.不定期的更新文章.更新技术. 关注公众号后请大家养成 不白嫖的习惯.欢迎大家赞赏. 也希望在看完公众号文章之后 不忘 点击 收藏 转发 以及点击在看功能. QQ群: