应用与内核通信

文章目录

  • 应用与内核通信
    • 5.3 阻塞、等待与安全设计
      • 5.3.1 驱动主动通知应用
      • 5.3.2 通信接口的测试
      • 5.3.3 内核中的缓冲区链表结构
      • 5.3.4 输入: 内核中的请求处理中的安全检查
      • 5.3.5 输出处理与卸载清理
      • 明日计划

5.3 阻塞、等待与安全设计

5.3.1 驱动主动通知应用

之前设计的功能需求中,还缺少一个应用从内核中读取之前保存的字符串。通信是双向的,从应用主动向内核发出请求是一件很容易的事——应用首先调用 **CreateFile**, 然后调用 **DeviceIoControl** ,就可以引发内核程序的反应。但是内核如何主动通知应用呢? 一个简单的办法: 使用内核独用的同步事件来等待。

​ 用户态随意调用 DeviceIoControl,在内核程序中的分发函数里等待一个同步事件即可。这样,在用户态看来,程序就像是被阻塞了一样。当内核想通知程序的时候,只要设置该同步事件即可。只要在用户态的线程里使用该 DeviceControl,就可以一边持续工作,一边等待内核的通知了。

5.3.2 通信接口的测试

​ 在驱动程序的实际开发中,控制台的用户态应用程序往往作为“测试者”。一个提供给用户态通信接口的驱动程序尤其荣誉暴露出内核漏洞。从而给整个内核带来被攻击的风险。所以在发布之前,需要用户态程序对通信接口进行各种可能的全面测试,来覆盖各种意外情况。

  • 如果输入缓冲区极大/极小会如何?
  • 如果输入缓冲区刚好等于规定的最大/最小长度会如何?
  • 如果输入的字符串没有结束符会如何?
  • 如果输入的字符串长度为0会如何。
  • 只涉及逻辑关系简单、具有可测试行的通信接口。
  • 只实习通信中各种可能的情况做充分的测试。

​ 本章规定的接口较为简答,只有两个接口: 一个为发送字符串接口(字符串从应用到内核),一个为接受字符串接口(字符串从内核到应用)。

5.3.3 内核中的缓冲区链表结构

​ 在内核中将使用一个双向链表来保存所有已经输入的字符串。每当应用层来取字符串的时候,每次返回其中一个,并从链表中删除。保证一个先入先出的顺序。

//定义一个链表来保存字符串
#define CWK_STR_LEN_MAX 512
typedef struct{LIST_ENTRY list_entry;char buf[CWK_STR_LEN_MAX];
}CWT_STR_NODE;//还必须有一个自旋锁来保证链表操作的安全性
KSPIN_LOCK g_cwk_lock;
//用一个事件来标识是否有字符串可以取
KEVENT g_cwk_enent;
//必须有一个链表头
LIST_ENTRY g_cwk_str_list;

​ 上面这个数据结构的好处是长度固定,但是非常浪费空间。但是我们作为测试用例就无所谓。所有链表结构中的空间都是动态分配的。

// 分配内存并初始化一个链表节点
CWK_STR_NODE *cwkMallocStrNode()
{CWK_STE_NODE *ret = ExAllocatePoolWithTag(NonPagedPool,sizeof(CWK_STR_NODE).MEM_TAG);if(ret == NULL)return NULL;return ret;
}

记得在删除的时候要释放内存。

5.3.4 输入: 内核中的请求处理中的安全检查

​ 接下来回到从应用层发送字符串到内核。内核程序的分发函数的处理中,首先就是对输入缓冲区的检查,确保缓冲区的长度符号正确的要求。

​ 注意不要把 ASSERT(断言)和安全检查混淆起来。

​ 断药只有在检查版本中才被编译。断言是为了提示开发者,仅仅用于调试。断言不能出现在发行版本中,因为被编译到发行版本中的断言能够导致系统蓝屏。

​ 安全检查的原则之一是越简单越好。当我们将输入缓冲区的长度限制为512字节时,这就意味着攻击者可能使用的输入缓冲区的长度范围从无限大急剧地缩小到了512字节。

​ 输入缓冲区的长度即使是正确的,也必须要检查输入缓冲区的内容。不可以使用 strlen 函数来检查输入缓冲区的长度。因为该函数并没有限度,它从第一个字符串开始搜索直到找到结束符为止。如果一直没有结束符,就会一直找下去,这完全可能会导致异常。

​ 所以我们使用 strnlen 来检查字符串的长度。它有一个最大限度,超过这个限度就不会再往后搜索结束符了。

//安全的编程态度,使用strnlen而不是strlen来检查长度
DbgPrint("strnlen = %d\r\n,strnlen(char *)buffer,inlen);
if(strnlen((char *)buffer,inlen) == inlen)
//字符串沾满了缓冲区,并且中间没有结束符,立刻返回错误status = STATUS_INVALID_PARAMETER;break;
//如果成功的找到了结束符,则可以认为是输入有效的,这时候才继续处理。
str_node = cwMallocStrNode();
if(str_node == NULL)
{//如果分配不到空间了,则返回资源不足status = STATUS_INSUFFICIENT_RESOURCES;break;
}
...

​ 如果后续要拷贝字符串。按道理说使用 strcpy 来拷贝,也会和 strlen 有一样的不安全因素。但是我们在分配空间之前已经确认了输入的字符室友带结束符的,所以后续可以放心的使用 strcpy 了。但是处于良好的习惯,我们还是使用 strncpy来拷贝字符串。

strncpy(str_node->buf,(char *)buffer,CWK_STR_LEN_MAX);
//插入到链表末尾,用锁来保证安全性
ExInterlockedInsertTailList(&g_cwk_str_list,(PLIST_ENTRY)str_node,&g_cwk_lock);
//现在就可以认为这个请求已经成功,因为
//刚刚已经成功插入了一个字符串,那么可以设置事件结束来表明队列中已经有元素了
KeSetEvent(&g_cwk_event,0,TRUE);
break;
...

​ 这里使用了 ExInterlockerInsertTailLIst 来把新的字符串链表节点插入到缓冲区中,是因为该驱动可以同时接受很多个进程来获取或者存储字符串,但是每次去除的字符串只发给第一个索取者。因此,这段代码随时都可能是有多个线程同时执行的,所以使用锁来保证多线程安全性。

5.3.5 输出处理与卸载清理

​ 同样,在处理接受字符串请求的时候,也就是应用层要i求从内核读取字符串的时候,也要做类似的安全处理。

​ 在对内存不敏感的时候,直接要求应用层给出足够大的缓冲区来接受字符串。

//应用要求接受字符串。对此,要求输出缓冲区要足够长
if(outlen < CWK_STRL_LEN_MAX)
{statsu = STATUS_INVALID_PARAMETER;break;
}
......

​ 这里使用 while 循环来一直检测链表里是否有字符串可以返回,但是持续的询问是多余的,可以使用 sleep 来减少CPU的占有率。

while(1)
{//从链表头取数据,用锁来保证安全性str_node = (CWK_STR_NODE *)ExInterlockedRemoveHeadList(&g_cwk_str_list,&g_cwk_lock);if(str_node != NULL){//一旦取得了字符串,就拷贝到缓冲区中strncpy((char *)buffer,str_node-buf,CWK_STR_LEN_MAX);ret_len = strnlen(str_node-buf,CWK_STR_LEN_MAX)+1;ExFreePool(str_node);break;}else{//如果链表为空,则事件进行等待KeWaitForSingleObject(&g_cwk_event,Executive,KernelMode,0,0);}}
break;

​ 最后,不要忘了在卸载函数里增加释放所有的内存的代码。

给出完整的内核态代码供实验:

//Download by www.cctry.com
///
/// @file   coworker_sys.c
/// @author tanwen
/// @date   2012-5-28
///#include <ntifs.h>
#include <wdmsec.h>PDEVICE_OBJECT g_cdo = NULL;const GUID  CWK_GUID_CLASS_MYCDO =
{0x17a0d1e0L, 0x3249, 0x12e1, {0x92,0x16, 0x45, 0x1a, 0x21, 0x30, 0x29, 0x06}};#define CWK_CDO_SYB_NAME    L"\\??\\slbkcdo_3948d33e"// 从应用层给驱动发送一个字符串。
#define  CWK_DVC_SEND_STR \(ULONG)CTL_CODE( \FILE_DEVICE_UNKNOWN, \0x911,METHOD_BUFFERED, \FILE_WRITE_DATA)// 从驱动读取一个字符串
#define  CWK_DVC_RECV_STR \(ULONG)CTL_CODE( \FILE_DEVICE_UNKNOWN, \0x912,METHOD_BUFFERED, \FILE_READ_DATA)// 定义一个链表用来保存字符串
#define CWK_STR_LEN_MAX 512
typedef struct {LIST_ENTRY list_entry;char buf[CWK_STR_LEN_MAX];
} CWK_STR_NODE;// 还必须有一把自旋锁来保证链表操作的安全性
KSPIN_LOCK g_cwk_lock;
// 一个事件来标识是否有字符串可以取
KEVENT  g_cwk_event;
// 必须有个链表头
LIST_ENTRY g_cwk_str_list;#define MEM_TAG 'cwkr'// 分配内存并初始化一个链表节点
CWK_STR_NODE *cwkMallocStrNode()
{CWK_STR_NODE *ret = ExAllocatePoolWithTag(NonPagedPool, sizeof(CWK_STR_NODE), MEM_TAG);if(ret == NULL)return NULL;return ret;
}void cwkUnload(PDRIVER_OBJECT driver)
{UNICODE_STRING cdo_syb = RTL_CONSTANT_STRING(CWK_CDO_SYB_NAME);CWK_STR_NODE *str_node;ASSERT(g_cdo != NULL);IoDeleteSymbolicLink(&cdo_syb);IoDeleteDevice(g_cdo);// 负责的编程态度:释放分配过的所有内核内存。while(TRUE){str_node = (CWK_STR_NODE *)ExInterlockedRemoveHeadList(&g_cwk_str_list, &g_cwk_lock);// str_node = RemoveHeadList(&g_cwk_str_list);if(str_node != NULL)ExFreePool(str_node);elsebreak;};
}NTSTATUS cwkDispatch(      IN PDEVICE_OBJECT dev,IN PIRP irp)
{PIO_STACK_LOCATION  irpsp = IoGetCurrentIrpStackLocation(irp);NTSTATUS status = STATUS_SUCCESS;ULONG ret_len = 0;while(dev == g_cdo) {// 如果这个请求不是发给g_cdo的,那就非常奇怪了。// 因为这个驱动只生成过这一个设备。所以可以直接// 返回失败。if(irpsp->MajorFunction == IRP_MJ_CREATE || irpsp->MajorFunction == IRP_MJ_CLOSE){// 生成和关闭请求,这个一律简单地返回成功就可以// 了。就是无论何时打开和关闭都可以成功。break;}if(irpsp->MajorFunction == IRP_MJ_DEVICE_CONTROL){// 处理DeviceIoControl。PVOID buffer = irp->AssociatedIrp.SystemBuffer;  ULONG inlen = irpsp->Parameters.DeviceIoControl.InputBufferLength;ULONG outlen = irpsp->Parameters.DeviceIoControl.OutputBufferLength;ULONG len;CWK_STR_NODE *str_node;switch(irpsp->Parameters.DeviceIoControl.IoControlCode){case CWK_DVC_SEND_STR:ASSERT(buffer != NULL);ASSERT(outlen == 0);// 安全的编程态度之一:检查输入缓冲的长度对于长度超出预期的,果// 断返回错误。if(inlen > CWK_STR_LEN_MAX){status = STATUS_INVALID_PARAMETER;break;                    }// 安全的编程态度之二:检查字符串的长度,不要使用strlen!如果使// 用strlen,一旦攻击者故意输入没有结束符的字符串,会导致内核驱// 动访问非法内存空间而崩溃。DbgPrint("strnlen = %d\r\n", strnlen((char *)buffer, inlen));if(strnlen((char *)buffer, inlen) == inlen){// 字符串占满了缓冲区,且中间没有结束符。立刻返回错误。status = STATUS_INVALID_PARAMETER;break;                    }// 现在可以认为输入缓冲是安全而且不含恶意的。分配节点。str_node = cwkMallocStrNode();if(str_node == NULL){// 如果分配不到空间了,返回资源不足的错误status = STATUS_INSUFFICIENT_RESOURCES;break;}// 前面已经检查了缓冲区中的字符串的确长度合适而且含有结束符// ,所以这里用什么函数来拷贝字符串对安全性而言并不非常重要。strncpy(str_node->buf,(char *)buffer, CWK_STR_LEN_MAX); // 插入到链表末尾。用锁来保证安全性。ExInterlockedInsertTailList(&g_cwk_str_list, (PLIST_ENTRY)str_node, &g_cwk_lock);// InsertTailList(&g_cwk_str_list, (PLIST_ENTRY)str_node);// 打印// DbgPrint((char *)buffer);// 那么现在就可以认为这个请求已经成功。因为刚刚已经插入了一// 个,那么可以设置事件来表明队列中已经有元素了。KeSetEvent(&g_cwk_event, 0, TRUE);break;case CWK_DVC_RECV_STR:ASSERT(buffer != NULL);ASSERT(inlen == 0);// 应用要求接收字符串。对此,安全上要求是输出缓冲要足够长。if(outlen < CWK_STR_LEN_MAX){status = STATUS_INVALID_PARAMETER;break;                    }while(1) {// 从链表头取出数据。用锁来保证安全性。str_node = (CWK_STR_NODE *)ExInterlockedRemoveHeadList(&g_cwk_str_list, &g_cwk_lock);// str_node = RemoveHeadList(&g_cwk_str_list);if(str_node != NULL){// 这种情况下,取得了字符串。那就拷贝到输出缓冲中。然后// 整个请求就返回了成功。strncpy((char *)buffer, str_node->buf, CWK_STR_LEN_MAX);ret_len = strnlen(str_node->buf, CWK_STR_LEN_MAX) + 1;ExFreePool(str_node);break;}else{// 对于合法的要求,在缓冲链表为空的情况下,等待事件进行// 阻塞。也就是说,如果缓冲区中没有字符串,就停下来等待// 。这样应用程序也会被阻塞住,DeviceIoControl是不会返回// 的。但是一旦有就会返回。等于驱动“主动”通知了应用。KeWaitForSingleObject(&g_cwk_event,Executive,KernelMode,0,0);}}break;default:// 到这里的请求都是不接受的请求。未知的请求一律返回非法参数错误。status = STATUS_INVALID_PARAMETER;break;}}break;}// 返回结果irp->IoStatus.Information = ret_len;irp->IoStatus.Status = status;IoCompleteRequest(irp,IO_NO_INCREMENT);return status;
}NTSTATUS DriverEntry(PDRIVER_OBJECT driver,PUNICODE_STRING reg_path)
{NTSTATUS status;ULONG i;UCHAR mem[256] = { 0 };// 生成一个控制设备。然后生成符号链接。UNICODE_STRING sddl = RTL_CONSTANT_STRING(L"D:P(A;;GA;;;WD)");UNICODE_STRING cdo_name = RTL_CONSTANT_STRING(L"\\Device\\cwk_3948d33e");UNICODE_STRING cdo_syb = RTL_CONSTANT_STRING(CWK_CDO_SYB_NAME);KdBreakPoint();// 生成一个控制设备对象。status = IoCreateDeviceSecure(driver,0,&cdo_name,FILE_DEVICE_UNKNOWN,FILE_DEVICE_SECURE_OPEN,FALSE,&sddl,(LPCGUID)&CWK_GUID_CLASS_MYCDO,&g_cdo);if(!NT_SUCCESS(status))return status;// 生成符号链接.IoDeleteSymbolicLink(&cdo_syb);status = IoCreateSymbolicLink(&cdo_syb,&cdo_name);if(!NT_SUCCESS(status)){IoDeleteDevice(g_cdo);return status;}// 初始化事件、锁、链表头。KeInitializeEvent(&g_cwk_event,SynchronizationEvent,TRUE); KeInitializeSpinLock(&g_cwk_lock);InitializeListHead(&g_cwk_str_list);// 所有的分发函数都设置成一样的。for(i=0;i<IRP_MJ_MAXIMUM_FUNCTION;i++){driver->MajorFunction[i] = cwkDispatch;}// 支持动态卸载。driver->DriverUnload = cwkUnload;// 清除控制设备的初始化标记。g_cdo->Flags &= ~DO_DEVICE_INITIALIZING;return STATUS_SUCCESS;
}

完整的用户态代码:

//Download by www.cctry.com
// coworker_user.cpp : 定义控制台应用程序的入口点。
//#include "stdafx.h"#define CWK_DEV_SYM L"\\\\.\\slbkcdo_3948d33e"// 从应用层给驱动发送一个字符串。
#define  CWK_DVC_SEND_STR \(ULONG)CTL_CODE( \FILE_DEVICE_UNKNOWN, \0x911,METHOD_BUFFERED, \FILE_WRITE_DATA)// 从驱动读取一个字符串
#define  CWK_DVC_RECV_STR \(ULONG)CTL_CODE( \FILE_DEVICE_UNKNOWN, \0x912,METHOD_BUFFERED, \FILE_READ_DATA)int _tmain(int argc, _TCHAR* argv[])
{HANDLE device = NULL;ULONG ret_len;int ret = 0;char *msg = {"Hello driver, this is a message from app.\r\n"};char tst_msg[1024] = { 0 };// 打开设备.每次要操作驱动的时候,先以此为例子打开设备device=CreateFile(CWK_DEV_SYM,GENERIC_READ|GENERIC_WRITE,0,0,OPEN_EXISTING,FILE_ATTRIBUTE_SYSTEM,0);if (device == INVALID_HANDLE_VALUE){printf("coworker demo: Open device failed.\r\n");return -1;}elseprintf("coworker demo: Open device successfully.\r\n");if(!DeviceIoControl(device, CWK_DVC_SEND_STR, msg, strlen(msg) + 1, NULL, 0, &ret_len, 0)){printf("coworker demo: Send message failed.\r\n");ret = -2;}elseprintf("coworker demo: Send message successfully.\r\n");// 这里开始,其实是对驱动的一系列测试。分配3个字符串:// 1.长度为0.应该可以正常输入。// 2.长度为511字节,应该可以正常输入。// 3.长度为512字节,应该返回失败。// 4.长度为1024字节的字符串,但声明缓冲区长度为128,应该返回失败。// 5.第一次读取,应该读出msg的内容。// 5.第一次读取,应该读出长度为511字节的字符串。// 6.第二次读取,应该读出长度为0的字符串。do {memset(tst_msg, '\0', 1);if(!DeviceIoControl(device, CWK_DVC_SEND_STR, tst_msg, 1, NULL, 0, &ret_len, 0)){ret = -3;break;}else{printf("TEST1 PASS.\r\n");}memset(tst_msg, '\0', 512);memset(tst_msg, 'a', 511);if(!DeviceIoControl(device, CWK_DVC_SEND_STR, tst_msg, 512, NULL, 0, &ret_len, 0)){ret = -5;break;}else{printf("TEST2 PASS.\r\n");}memset(tst_msg, '\0', 513);memset(tst_msg, 'a', 512);if(DeviceIoControl(device, CWK_DVC_SEND_STR, tst_msg, 513, NULL, 0, &ret_len, 0)){// 这个缓冲区已经过长,理应返回失败。如果成功了则// 认为是错误。ret = -5;break;}else{printf("TEST3 PASS.\r\n");}memset(tst_msg, '\0', 1024);memset(tst_msg, 'a', 1023);if(DeviceIoControl(device, CWK_DVC_SEND_STR, tst_msg, 128, NULL, 0, &ret_len, 0)){// 这个缓冲区虽然不过长,但是字符串过长,理应返回失// 败。如果成功了则认为是错误。ret = -5;break;}else{printf("TEST4 PASS.\r\n");}free(tst_msg);// 现在开始测试输出。第一个读出的应该是msg.if(DeviceIoControl(device, CWK_DVC_RECV_STR, NULL, 0, tst_msg, 1024, &ret_len, 0) == 0 || ret_len != strlen(msg) + 1){ret = -6;break;}else {printf("TEST5 PASS.\r\n");}// 第二个读出的应该是长度为0的空字符串。if(DeviceIoControl(device, CWK_DVC_RECV_STR, NULL, 0, tst_msg, 1024, &ret_len, 0) == 0 || ret_len != 1){ret = -6;break;}else {printf("TEST6 PASS.\r\n");}// 第三个读出的应该是长度为511的全a字符串if(DeviceIoControl(device, CWK_DVC_RECV_STR, NULL, 0, tst_msg, 1024, &ret_len, 0) != 0 || ret_len != 511 + 1){ret = -6;break;}else {printf("TEST7 PASS.\r\n");}} while(0);CloseHandle(device);return ret;
}

明日计划

驱动编程第六章 64位和32位内核开发差异

《Windows内核安全与驱动编程》-第五章阻塞、等待与安全设计相关推荐

  1. Windows内核安全与驱动编程学习笔记----1.WDK安装

    WDK安装安装 1.WDK下载 VS2019设置 EWDK使用 注意事项 错误解决方法 系统错误1275 系统错误557 1.WDK下载 微软已经不再使用connect.microsoft.com,新 ...

  2. Windows内核安全与驱动开发

    这篇是计算机中Windows Mobile/Symbian类的优质预售推荐<Windows内核安全与驱动开发>. 编辑推荐 本书适合计算机安全软件从业人员.计算机相关专业院校学生以及有一定 ...

  3. Android深度探索--HAL与驱动开发----第五章读书笔记

    第五章主要学习了搭建S3C6410开发板的测试环境.首先要了解到S3C6410是一款低功耗.高性价比的RISC处理器它是基于ARMI1内核,广泛应用于移动电话和通用处理等领域. 开发板从技术上说与我们 ...

  4. 聚焦3D地形编程第五章GeomipMapping for the CLOD

    第二部分高级地形编程 聚焦3D地形编程第五章GeomipMapping for the CLOD 译者: 神杀中龙 邵小宁 microsoftxiao@163.com 翻译的烂请见谅 原著 <F ...

  5. linux简单设计与实现代码,《linux内核设计与实现》第五章(示例代码)

    第五章 系统调用 一.与内核通信 系统调用在用户空间进程和硬件设备之间添加了一个中间层.作用: 为用户空间提供了一种硬件的抽象接口. 系统调用保证了系统的稳定和安全. 每个进程都运行在虚拟系统中,而在 ...

  6. Linux内核分析 读书笔记 (第五章)

    第五章 系统调用 5.1 与内核通信 1.调用在用户空间进程和硬件设备之间添加了一个中间层.该层主要作用有三个: 为用户空间提供了硬件的抽象接口. 系统调用保证了系统的稳定和安全. 实现多任务和虚拟内 ...

  7. linux内核源代码情景分析(第五章 文件系统)

    第五章 文件系统 5.1 概述 5.2 从路径名到目标节点 5.3 访问权限与文件安全性 5.4 文件系统的安装与卸载 5.5 文件的打开与关闭 5.6 文件的写和读 5.7 其他文件操作 5.8 特 ...

  8. Windows核心编程 第五章 作业(上)

    第5章 作 业 通常,必须将一组进程当作单个实体来处理.例如,当让 Microsoft Developer Studio为你创建一个应用程序项目时,它会生成 C l . e x e,C l . e x ...

  9. Windows核心编程 第五章 作业(下)

    5.4 查询作业统计信息 前面已经介绍了如何使用 Q u e r y I n f o r m a t i o n J o b O b j e c t函数来获取对作业的当前限制信息.也可以使用它来获取关 ...

最新文章

  1. 在移动硬盘里移动视频文件到移动硬盘 另外一个文件夹 显示正在计算_古风玩数码 篇九十六:物超所值?移动固态硬盘到底值不值?阿斯加特移动硬盘AP2上手体验_固态硬盘...
  2. mysql 全值匹配什么意思
  3. Linux系统gdb工具使用,使用 GDB 工具调试 Go
  4. 【运营】产品经理必须了解的运营方法,让你的产品有产有销
  5. JAVA51报错,好象是占溢出错误,不知道怎么改
  6. windows编程一日一练(3)
  7. javascript --- 使用对象关联简化整体设计
  8. java第九章编写一个能够产生_第九章java教程.ppt
  9. 外设驱动库开发笔记2:AD8400系列数字电位器驱动
  10. html div 水平垂直居中显示,利用CSS实现div水平垂直居中
  11. 发布一个mmap的trie_midrmm02_新浪博客
  12. LeetCode-----二维数组中的查找
  13. redisson笔记
  14. NumPy学习笔记之argsort()函数
  15. latex---插入三线表伪代码流程图
  16. 分享:映像编辑工具Ghostexp
  17. 物联网系列②——使用ESP8266与STM32进行物联网开发板设计
  18. zabbix监控平台设置报警发送邮件
  19. 知识图谱数据管理:存储与检索
  20. 华南师范大学计算机学院教务,促进教考协调,创新教育形式,服务人才培养 ——计算机学院2016-2017(1)学期期末考试工作纪实...

热门文章

  1. Bugtags 那些事儿
  2. web网页短信系统平台后台源码搭建功能篇|移讯云短信系统
  3. APS系统哪家好(上)
  4. Neusoft——智能网联无线通信技术
  5. excel中批量删除公式,保留数值
  6. AgileConfig 一个轻量级配置中心
  7. .mdb文件导入到mysql(工具Navicat Premium 12)
  8. ArcGIS学习总结(14)——DEM数据处理与等高线生成
  9. dg的奇怪问题终结和分区问题答疑 (r7笔记第77天)
  10. Sim3D 的语言设定