分类

类型:
不限 游戏开发 计算机程序开发 Android开发 网站开发 笔记总结 其他
评分:
不限 10 9 8 7 6 5 4 3 2 1
原创:
不限
年份:
不限 2018 2019

文章列表

  • PC微信逆向分析のWeTool内部探秘

    作者:zmrbak(赵庆明老师)
    前言先不说微信在社交领域的霸主地位,我们仅从腾讯公司所透露的在研发微信过程中踩过的无数的坑,以及公开的与微信相关的填坑的源码中,我们可以感受到,单从技术上讲,微信是一款非常伟大的产品。然而,伟大的产品,往往会被痴迷于技术的人送进实验室,运用各种可能的工具将其大卸八块,以参悟其“伟大”之所在!。
    WeTool,一款免费的微信社群管理工具,正是一群痴迷于技术的人对于微信这个伟大的产品的研究而得到的成果。在微商界,这个软件真可谓是鼎鼎大名、如雷贯耳。如果你还不知晓这个软件,那么你肯定不是微商界的人。如果你想对你的微信群进行管理,而又不想花钱,也许这个软件就是你最佳的选择。当然,免费软件的套路都是一样的,WeTool“有意地”不满足你的一些特殊需求,如果真的很想要的话,当然是要付费的,那就购买“企业版”吧。
    但是,对于一个对技术有强烈兴趣的人来说,研究WeTool与研究PC微信一样有趣,在这里,我把它们两个一起送进实验室,一窥其中的奥秘!
    微信中的WeTool由于腾讯干预,目前WeTool免费版本已不再公开提供下载。但之前的旧版本仍然可以自动升级到最新版。如果你想获得WeTool这个软件,我想,你应该知道该怎么做了吧。如果你还是不知道,很抱歉,这篇文章对你来说太深奥了。那么我对你的建议是:关掉这个网页吧。
    WeTool在启动的时候,会检查当前计算机上是否安装了版本匹配的PC微信。倘若找不到,或者版本不匹配,WeTool会引导你到它的官网去下载一个版本匹配的PC微信(可能比较旧,但能用)。下载完毕后,还需要你手动去安装一下。
    在WeTool启动的时候,还会检查微信的登录状态,如果微信还未完成登录,WeTool会等待微信登录之后,再开启自己的管理界面。
    这里的问题是:WeTool是如何得知微信是否已经登录了呢?
    在这里,我们使用PCHunter来检查一下微信(WeChat.exe)的进程模块。我们可以看到,在微信的进程中加载了一个特殊的DLL文件(WeHelp.dll),而它的父目录是一个特殊的字符串:“2.6.8.65”,恰好与我们当前运行的微信版本一致。再上一层的目录,“WeToolCore”,很明显,这里的文件是WeTool的一部分。

    恰恰是这个DLL文件帮助WeTool完成了与微信之间的各种互动。也就是说,WeTool通过WeHelp.dll这个文件,可以感知到微信的各种活动,当然也包括微信是否已经登录等等…
    窥探WeTool如果在不经意之间关闭了WeTool,你会发现,你的微信也被关闭了。这又是为什么呢?
    如果你曾经用OD调试过软件,你会发现当你的OD被关闭的时候,被OD所调试的那个软件也被关闭掉了。因此,我们猜想,WeTool对于微信来说,应该使用的是类似于OD之于其他软件相同的原理,那就是“调试”。
    在WeTool管理你的微信的时候,你也会发现,这时候微信无法被OD所附加。其实,还是“调试”。当一个软件已经处于某个调试器的“调试”之下,为了防止出错,调试器会拒绝对这个已处于被调试中的软件的再次调试。这进一步印证了WeTool对于微信的“调试”的事实。
    然而就是这么一个“小小的”设置,就击碎不少“小白”想调试WeTool美梦。
    既然我们找到了WeTool对于微信的关键,那就是文件“WeHelp.dll”。那么,我们就把这个文件请入我们的实验室,让我们把它一点一点地拆开,细细探寻其中的一点一滴的奥秘。
    拆解WeTool在动手拆解之前,我们还是先了解一下WeTool到底向我们的计算机上安装了些什么东东。顺着桌面上的“WeTool 免费版”,我们找到了WeTool安装的目录,安装目录之下22个文件夹和84个文件。当然,让我们比较感兴趣的就是“WeChatVersion”这个文件夹,因为它的名字与微信(WeChat)太让人能联想到一起了。

    双击“WeChatVersion”,我们看到如下结果。恰好是以微信曾经的一个个版本号命名的文件夹。我们猜想,这个文件夹一定与这个版本的微信之间存在中某种联系。目前,我们可以得到最新的微信版本是2.6.8.68(此版本为更新版;从腾讯官网可下载到的版本仅为2.6.8.65),而这里恰好有一个以该版本号命名的文件夹“2.6.8.65”。

    我们双击打开“2.6.8.65”这个文件夹。文章前面所提到的“WeHelp.dll”文件赫然在目。点开其他类似微信版本号的文件夹,同样,每个文件夹中都有这两个文件。唯一的区别就是文件的大小不一样。
    由于我们使用的微信版本是2.6.8.65,那么我们就针对2.6.8.65文件夹下的这个“WeHelp.dll”进行研究。通过二进制对比,我们发现该文件夹下的“WeHelp.dll”文件与微信中加载的“WeHelp.dll” 文件为同一个文件。

    由此,我们得出结论:WeTool为不同版本的微信分别提供了不同的WeHelp.dll文件,在WeTool启动的时候,把WeChatVersion中对应与当前版本微信号的文件夹复制到当前Windows登录用户的应用程序数据文件夹中,然后再将里面的“WeHelp.dll”加载到微信进程的内存中。
    WeHelp解析WeTool为“WeHelp.dll”设置了一道阻止“动态调试”的障碍,这足以让所有的动态调试器,在没有特殊处理前,对它根本无法下手。
    如果能绕道而行,那何必强攻呢?于是我们请出静态分析的利器——IDA PRO 32。注意,这里务必使用32位版本的,因为只有在32位版本中,才可以把汇编代码转换成C语言的伪代码。相比于汇编代码来说,C代码就直观的多了。
    打开IDA,点击按钮“GO”,然后把WeHelp.dll拖入其中,接下来就是十几秒的解析,解析完毕后,界面如下:

    从IDA解析的结果中,让我们很惊奇的是,在“WeHelp.dll”中居然未发现什么加壳啊、加密啊、混淆啊等等这些对于程序版权保护的技术。也许是WeTool太自信了吧!毕竟WeTool是事实上的业界老大,其地位无人可以撼动。
    对于和微信之间交互的这部分功能来说,其实对于一个刚入门的、比较勤奋的逆向新手,只需经过半年到一年时间的练手,这部分功能也是可以完成。对于WeTool来说,其真正的核心价值不在这里,而在于其“正向”的管理逻辑,以及自己后台的Web服务器。在它的管理界面,各种功能实现里逻辑错综复杂,如果你想逆向的话,还不如重写算了,况且它都已经免费给你用了,还有必要逆向吗!!当然,WeTool后台的服务器,你根本就碰不到。
    从IDA解析的结果中,可以看到WeHelp中各个函数、方法,毫无遮拦地完全展示在眼前。而在右侧的窗口中,按下F5,瞬间汇编代码变成了C语言的伪代码。

    对于一个稍稍有一些Window API编程经验的人来说,这些全部都是似曾相识的C代码,只需简单地猜一猜,就能看明白写的是啥。如果还是不懂的话,那就打开Visual Studio,对照着看吧。这里是DllMain,也就是DLL的入口函数。我们还是来创建一个C++的动态链接库(dll)的项目,来对照着看吧:

    fdwReason=1,恰好,DLL_PROCESS_ATTACH=1。一旦DLL被加载,则马上执行DllMain这个函数中的DLL_PROCESS_ATTACH分支。也就是说,当“WeHelp.dll”这个文件被微信加载到进程之后,马上执行一下DllMain函数,DLL_PROCESS_ATTACH分支里面的这两个函数就会马上执行。

    鼠标双击第一个函数(sub_10003040),到里面去看看这个函数里面有啥,如下图,它的返回值来自于一个Windows Api(桃红色字体)——“RegisterWindowMessageW”,查看MSDN后,发现,原来是注册Windows消息。
    这不是我们最想要的,按ESC键,返回。
    鼠标双击下一个函数(sub_100031B0),页面变成这个啦。很明显,在注册一个窗口类。对于一个窗口来说,最重要的就是它的回调函数,因为要在回调函数中,完成对窗口的所有事件处理。这里,lpfnWndProc= sub_10003630,就很明显了,这就是回调函数。

    双击sub_10003630这个函数,窗口切换为如下内容。除了第一条语句的if之外,剩下的if…else if…else if是那么的引人注目。每一个比较判断之后,都调用了一个函数。而判断的依据是传入的参数“lParam”要与一个dword的值比较。
    我们猜测,这些函数大概是WeHelp和微信之间交互相关的函数吧。当然,这只是猜测,我们还要进一步验证才行。

    sub_10003630这个函数,是窗口的回调函数,我们要重点关注。那么,我们先给它改个名字吧。在函数名上点右键,选中“Rename global item”,我们取个名字叫“Fn_WndProc”吧。于是页面就变成了这样:

    虽然在IDA中,“WeHelp.dll”中的函数(方法)全部显示出来了,但是也有40多个呢,我们找个简单一点的来试试。CWeHelp::Logout(void),这个函数没有参数,那么我们就选这个吧。在左侧函数窗口中双击CWeHelp::Logout(void),右侧窗口换成了这个函数的C语言伪代码(如果你显示的还是汇编,请点击汇编代码后按F5)。

    在前面,我们看到,在回调函数中,lParam要与一个dword值进行比较。在这个函数中,我们发现,这里为lParam赋了一个dword类型的值。为了方便记忆,我们把这个dowrd值改个名字吧,因为是Logout函数中用到的数字,那么就叫做“D_Logout”吧。

    接下来,我们要看看”还有谁”在用这个数值。在我们修改后的“D_Logout”上点右键,选择“Jump to xref…”。原来这个数值只有两个地方使用,一个就是当前的“Logout”函数,而另一个却是在”Fn_WndProc”中,那不就是前面的那个回调函数嘛!选中“Fn_WndProc”这一行,点击OK!

    又一次看到了熟悉的if…else if…,还有和“D_Logout”进行比较的分支,而这个分支里面只调用了一个函数sub_10005940,而且不带参数。

    双击函数“sub_10005940”后,发现这个函数很简单。核心语句只有两条,首先调用了sub_100030F0函数,然后得到一个返回值。接下来,为这个返回值加上一个数值“0x3F2BF0”,再转换成一个函数指针,再给一个0作为参数,再调用这个函数指针。最后返回结果。

    我们这里要关注,来自于“sub_100030F0”函数的返回值result到底是什么?
    同样,双击这个函数(sub_100030F0),进去看看呗!原来,调用了一个Windows API函数(GetModuleHandleW),查看MSDN后,发现原来这个函数的功能就是取微信的基址(WeChatWin.dll)。

    那就简单多了!是不是说,如果我们在微信中执行“((int (__stdcall *)(_DWORD))(weChatWinBaseAddress + 0x3F2BF0))(0);”这么一句代码,就可以实现Logout功能呢?
    当然,这只是猜测,我们还需要进一步验证。
    猜测验证打开原来创建的C++的动态链接库项目,把 case分支DLL_PROCESS_ATTACH换成如下内容:
    case DLL_PROCESS_ATTACH: { DWORD weChatWinBaseAddress = (int)GetModuleHandleW(L"WeChatWin.dll"); ((int(__stdcall*)(DWORD))(weChatWinBaseAddress + 0x3F2BF0))(0); }
    注意:把从IDA中拷贝过来的代码中 “_DWORD”中的下划线去掉,就可以编译通过了。

    启动微信,登录微信(这时候,手机微信中会显示“Windows微信已登录”)。
    使用OD附加微信(确保WeTool已经退出,否则OD附加不成功)。
    在OD汇编代码窗口点右键,选择”StrongOD\InjectDll\Remote Thread”,选中刚才Visual Studio中编译成功的那个dll文件。

    一秒钟!OK,神奇的事情发生了:微信提示,你已退出微信!
    同时,手机微信上原来显示的 “Windows微信已登录”,也消失了。
    从这里我们可以确定,微信“真的”是退出了,而不是崩掉了。

    总结其实逆向研究,并不只是靠苦力,更重要的是强烈的好奇心和发散的思维。也许一个瞬间,换一下思维模式,瞬间一切都开朗了。一个人的力量是有限的,融入一个圈子,去借鉴别人的成功经验,同时贡献自己成功经验,你会发现,逆向研究其实是一件非常有趣的事情。当然,我研究的后编写源码都是免费公开的,你可以到GitHub(https://github.com/zmrbak/PcWeChatHooK)上下载,也欢迎和我们一起学习和研究。
    后记2019年3月17日前,本人对WeTool进行过一些探索,始终没有取得进展。时隔两个月之后,突然有所发现,于是在2019年5月29日将我的探索成果录制成一个个视频,分享给和我一起研究和探索微信的好朋友。结果,在他们中激起了强烈的反响,有不少的朋友的研究进度有了一个飞跃性的发展,还有几个朋友短短几日之间,就远远超越了我的研究进度。看到这样的场景,让我的内心充满欢喜。当然,还有不少朋友的评价,更是让我开心,现摘录如下:











    源码分享:https://github.com/zmrbak/PcWeChatHooK
    2 回答 2019-09-14 15:14:33
  • 内核双机调试——追踪并分析内核API函数的调用

    在64位Win10主机上调试32位Win7虚拟机内核,查看Win7内核中的函数调用关系,从而分析API函数实现的具体原理和流程。
    一、环境配置前提,对Win7虚拟机设置了调试的COM端口:\\.\pipe\com_1,波特率设置为115200,那么接下来就可以使用WinDbg来进行调试了。具体步骤如下所示:
    首先,在Win10上“以管理员身份”运行64位的WinDbg,点击File—>Kernel Debug—>COM,波特率设置为115200,端口与上面对应\\.\pipe\com_1,并勾选pipe、Reconnect,点击确定即可。

    如果我们不动它,WinDbg命令窗口将会一直显示“Waiting to reconnect…”的信息!这时,我们需要点击下工具栏上“Break”按钮,让Win7系统断下来,这样我们才可以用WinDbg进行调试,输入控制指令。


    接着,设置符号路径:File—>Symbol File Path—>输入:
    srv*c:\symbols*https://msdl.microsoft.com/download/symbols确定即可,这样WinDbg就会自动根据链接下载符号表到本地上。

    等待一会儿,即可下载完毕,方可输入指令。
    二、使用WingDbg追踪API调用流程接下来,我们以分析内核API函数NtQueryDirectoryFile为例,介绍WinDbg调试软件的使用方法。
    输入指令:uf nt!NtQueryDirectoryFile

    大概可以看出,NtQueryDirectoryFile函数的实现主要是调用了nt!BuildQueryDirectoryIrp函数构造查询文件的IRP以及nt!IopSynchronousServiceTail发送IRP来实现的。为了验证我们的想法,我们继续对这两个函数进行查看。
    输入指令:uf nt!BuildQueryDirectoryIrp

    从它函数实现调用了nt!IoAllocateIrp函数可知,我们的猜想是正确的。
    输入指令:uf nt!IopSynchronousServiceTail

    从它函数实现调用了nt!IofCallDriver函数可知,我们的猜想是正确的。
    nt!IofCallDriver函数定义如下所示:
    NTSTATUS IofCallDriver( PDEVICE_OBJECT DeviceObject, __drv_aliasesMem PIRP Irp);
    该函数的功能就是将IRP发送到指定的设备对象中处理,第1个参数就是处理IRP的设备对象。
    所以,接下来,我们继续用WinDbg分析上述nt!IopSynchronousServiceTail函数将IRP发给了哪个驱动设备进行处理的。
    输入指令:bu nt!NtQueryDirectoryFile
    输入指令:bl;查看所有断点
    输入指令:g;继续往下执行
    下断点,只要系统执行到nt!NtQueryDirectoryFile这个函数就会停下来。

    输入指令:u @eip
    输入指令:r;查看寄存器

    由于是对nt! NtQueryDirectoryFile这个函数下断点,所以现在停下来,指令指针eip指向的就是nt! NtQueryDirectoryFile函数的入口地址。此时,uf @eip就是反汇编nt! NtQueryDirectoryFile函数的内容。
    输入指令:bp 84043fdc
    输入指令:g

    84043fdc就是nt!IopSynchronousServiceTail函数的入口地址,断点会自动在此处断下。我们一步步下断点,逼近最终我们需要下断点的nt!IofCallDriver函数,确保是由nt!NtQueryDirectoryFile函数内容实现调用的nt!IofCallDriver函数。
    输入指令:uf @eip

    输入指令:bp 83e4cd19
    输入指令:g

    83e4cd19是nt!IofCallDriver函数的入口地址,WinDbg断点断下后,就会自动停在此处。
    输入指令:u @eip
    输入指令:r

    IofCallDriver函数是FASTCALL类型的调用约定,所以第1个参数的值存储在寄存器ecx中,即873f3508。所以,我们继续查看下该设备对象的结构数据。
    输入指令:dt nt!_DEVICE_OBJECT @ecx

    这时,我们边可以获取到驱动对象的地址DriverObject(0x86f5e670 _DRIVER_OBJECT),继续查看驱动对象的数据内容。
    输入指令:dt nt!_DRIVER_OBJECT 0x86f5e670

    由DriverName中我们可以看出,nt!NtQueryDirectoryFile是将IRP请求包发送给FltMgf驱动程序来处理的。
    3 回答 2019-09-01 09:25:57
  • 基于python构建搜索引擎系列——(五)推荐阅读

    虽然主要的检索功能实现了,但是我们还需要一个“推荐阅读”的功能。当用户浏览某条具体新闻时,我们在页面底端给出5条和该新闻相关的新闻,也就是一个最简单的推荐系统。

    推荐模块的思路是度量两两新闻之间的相似度,取相似度最高的前5篇新闻作为推荐阅读的新闻。
    我们前面讲过,一篇文档可以用一个向量表示,向量中的每个值是不同词项t在该文档d中的词频tf。但是一篇较短的文档(如新闻)的关键词并不多,所以我们可以提取每篇新闻的关键词,用这些关键词的tfidf值构成文档的向量表示,这样能够大大减少相似度计算量,同时保持较好的推荐效果。
    jieba分词组件自带关键词提取功能,并能返回关键词的tfidf值。所以对每篇新闻,我们先提取tfidf得分最高的前25个关键词,用这25个关键词的tfidf值作为文档的向量表示。由此能够得到一个1000*m的文档词项矩阵M,矩阵每行表示一个文档,每列表示一个词项,m为1000个文档的所有互异的关键词(大概10000个)。矩阵M当然也是稀疏矩阵。
    得到文档词项矩阵M之后,我们利用sklearn的pairwise_distances函数计算M中行向量之间的cosine相似度,对每个文档,得到与其最相似的前5篇新闻id,并把结果写入数据库。
    推荐阅读模块的代码如下:
    from os import listdirimport xml.etree.ElementTree as ETimport jiebaimport jieba.analyseimport sqlite3import configparserfrom datetime import *import mathimport pandas as pdimport numpy as npfrom sklearn.metrics import pairwise_distancesclass RecommendationModule: stop_words = set() k_nearest = [] config_path = '' config_encoding = '' doc_dir_path = '' doc_encoding = '' stop_words_path = '' stop_words_encoding = '' idf_path = '' db_path = '' def __init__(self, config_path, config_encoding): self.config_path = config_path self.config_encoding = config_encoding config = configparser.ConfigParser() config.read(config_path, config_encoding) self.doc_dir_path = config['DEFAULT']['doc_dir_path'] self.doc_encoding = config['DEFAULT']['doc_encoding'] self.stop_words_path = config['DEFAULT']['stop_words_path'] self.stop_words_encoding = config['DEFAULT']['stop_words_encoding'] self.idf_path = config['DEFAULT']['idf_path'] self.db_path = config['DEFAULT']['db_path'] f = open(self.stop_words_path, encoding = self.stop_words_encoding) words = f.read() self.stop_words = set(words.split('\n')) def write_k_nearest_matrix_to_db(self): conn = sqlite3.connect(self.db_path) c = conn.cursor() c.execute('''DROP TABLE IF EXISTS knearest''') c.execute('''CREATE TABLE knearest (id INTEGER PRIMARY KEY, first INTEGER, second INTEGER, third INTEGER, fourth INTEGER, fifth INTEGER)''') for docid, doclist in self.k_nearest: c.execute("INSERT INTO knearest VALUES (?, ?, ?, ?, ?, ?)", tuple([docid] + doclist)) conn.commit() conn.close() def is_number(self, s): try: float(s) return True except ValueError: return False def construct_dt_matrix(self, files, topK = 200): jieba.analyse.set_stop_words(self.stop_words_path) jieba.analyse.set_idf_path(self.idf_path) M = len(files) N = 1 terms = {} dt = [] for i in files: root = ET.parse(self.doc_dir_path + i).getroot() title = root.find('title').text body = root.find('body').text docid = int(root.find('id').text) tags = jieba.analyse.extract_tags(title + '。' + body, topK=topK, withWeight=True) #tags = jieba.analyse.extract_tags(title, topK=topK, withWeight=True) cleaned_dict = {} for word, tfidf in tags: word = word.strip().lower() if word == '' or self.is_number(word): continue cleaned_dict[word] = tfidf if word not in terms: terms[word] = N N += 1 dt.append([docid, cleaned_dict]) dt_matrix = [[0 for i in range(N)] for j in range(M)] i =0 for docid, t_tfidf in dt: dt_matrix[i][0] = docid for term, tfidf in t_tfidf.items(): dt_matrix[i][terms[term]] = tfidf i += 1 dt_matrix = pd.DataFrame(dt_matrix) dt_matrix.index = dt_matrix[0] print('dt_matrix shape:(%d %d)'%(dt_matrix.shape)) return dt_matrix def construct_k_nearest_matrix(self, dt_matrix, k): tmp = np.array(1 - pairwise_distances(dt_matrix[dt_matrix.columns[1:]], metric = "cosine")) similarity_matrix = pd.DataFrame(tmp, index = dt_matrix.index.tolist(), columns = dt_matrix.index.tolist()) for i in similarity_matrix.index: tmp = [int(i),[]] j = 0 while j < k: max_col = similarity_matrix.loc[i].idxmax(axis = 1) similarity_matrix.loc[i][max_col] = -1 if max_col != i: tmp[1].append(int(max_col)) #max column name j += 1 self.k_nearest.append(tmp) def gen_idf_file(self): files = listdir(self.doc_dir_path) n = float(len(files)) idf = {} for i in files: root = ET.parse(self.doc_dir_path + i).getroot() title = root.find('title').text body = root.find('body').text seg_list = jieba.lcut(title + '。' + body, cut_all=False) seg_list = set(seg_list) - self.stop_words for word in seg_list: word = word.strip().lower() if word == '' or self.is_number(word): continue if word not in idf: idf[word] = 1 else: idf[word] = idf[word] + 1 idf_file = open(self.idf_path, 'w', encoding = 'utf-8') for word, df in idf.items(): idf_file.write('%s %.9f\n'%(word, math.log(n / df))) idf_file.close() def find_k_nearest(self, k, topK): self.gen_idf_file() files = listdir(self.doc_dir_path) dt_matrix = self.construct_dt_matrix(files, topK) self.construct_k_nearest_matrix(dt_matrix, k) self.write_k_nearest_matrix_to_db()if __name__ == "__main__": print('-----start time: %s-----'%(datetime.today())) rm = RecommendationModule('../config.ini', 'utf-8') rm.find_k_nearest(5, 25) print('-----finish time: %s-----'%(datetime.today()))
    这个模块的代码量最多,主要原因是需要构建文档词项矩阵,并且计算k邻居矩阵。矩阵数据结构的设计需要特别注意,否则会严重影响系统的效率。我刚开始把任务都扔给了pandas.DataFrame,后来发现当两个文档向量合并时,需要join连接操作,当数据量很大时,非常耗时,所以改成了先用python原始的list存储,最后一次性构造一个完整的pandas.DataFrame,速度比之前快了不少。
    本文转载自:http://bitjoy.net/2016/01/09/introduction-to-building-a-search-engine-5
    1 回答 2019-06-03 15:49:20
  • 基于python构建搜索引擎系列——(四)检索模型

    构建好倒排索引之后,就可以开始检索了。
    检索模型有很多,比如向量空间模型、概率模型、语言模型等。其中最有名的、检索效果最好的是基于概率的BM25模型。
    给定一个查询Q和一篇文档d,d对Q的BM25得分公式为:

    公式中变量含义如下:

    qtf:查询中的词频
    tf:文档中的词频
    ld:文档长度
    avg_l:平均文档长度
    N:文档数量
    df:文档频率
    b,k1,k3:可调参数

    这个公式看起来很复杂,我们把它分解一下,其实很容易理解。第一个公式是外部公式,一个查询Q可能包含多个词项,比如“苹果手机”就包含“苹果”和“手机”两个词项,我们需要分别计算“苹果”和“手机”对某个文档d的贡献分数w(t,d),然后将他们加起来就是整个文档d相对于查询Q的得分。
    第二个公式就是计算某个词项t在文档d中的得分,它包括三个部分。第一个部分是词项t在查询Q中的得分,比如查询“中国人说中国话”中“中国”出现了两次,此时qtf=2,说明这个查询希望找到的文档和“中国”更相关,“中国”的权重应该更大,但是通常情况下,查询Q都很短,而且不太可能包含相同的词项,所以这个因子是一个常数,我们在实现的时候可以忽略。
    第二部分类似于TFIDF模型中的TF项。也就是说某个词项t在文档d中出现次数越多,则t越重要,但是文档长度越长,tf也倾向于变大,所以使用文档长度除以平均长度ld/avg_l起到某种归一化的效果,k1和b是可调参数。
    第三部分类似于TFIDF模型中的IDF项。也就是说虽然“的”、“地”、“得”等停用词在某文档d中出现的次数很多,但是他们在很多文档中都出现过,所以这些词对d的贡献分并不高,接近于0;反而那些很稀有的词如”糖尿病“能够很好的区分不同文档,这些词对文档的贡献分应该较高。
    所以根据BM25公式,我们可以很快计算出不同文档t对查询Q的得分情况,然后按得分高低排序给出结果。
    下面是给定一个查询句子sentence,根据BM25公式给出文档排名的函数:
    def result_by_BM25(self, sentence): seg_list = jieba.lcut(sentence, cut_all=False) n, cleaned_dict = self.clean_list(seg_list) BM25_scores = {} for term in cleaned_dict.keys(): r = self.fetch_from_db(term) if r is None: continue df = r[1] w = math.log2((self.N - df + 0.5) / (df + 0.5)) docs = r[2].split('\n') for doc in docs: docid, date_time, tf, ld = doc.split('\t') docid = int(docid) tf = int(tf) ld = int(ld) s = (self.K1 * tf * w) / (tf + self.K1 * (1 - self.B + self.B * ld / self.AVG_L)) if docid in BM25_scores: BM25_scores[docid] = BM25_scores[docid] + s else: BM25_scores[docid] = s BM25_scores = sorted(BM25_scores.items(), key = operator.itemgetter(1)) BM25_scores.reverse() if len(BM25_scores) == 0: return 0, [] else: return 1, BM25_scores
    首先将句子分词得到所有查询词项,然后从数据库中取出词项对应的倒排记录表,对记录表中的所有文档,计算其BM25得分,最后按得分高低排序作为查询结果。
    类似的,我们还可以对所有文档按时间先后顺序排序,越新鲜的新闻排名越高;还可以按新闻的热度排序,越热门的新闻排名越高。
    关于热度公式,我们认为一方面要兼顾相关度,另一方面也要考虑时间因素,所以是BM25打分和时间打分的一个综合。
    比较有名的热度公式有两个,一个是Hacker News的,另一个是Reddit的,他们的公式分别为:


    可以看出,他们都是将新闻/评论的一个原始得分和时间组合起来,只是一个用除法,一个用加法。所以我们也依葫芦画瓢,”自创“了一个简单的热度公式:

    用BM25得分加上新闻时间和当前时间的差值的倒数,k1k1和k2k2也是可调参数。
    按时间排序和按热度排序的函数和按BM25打分排序的函数类似,这里就不贴出来了,详细情况可以看我的项目News_IR_Demo。
    至此,搜索引擎的搜索功能已经实现了,你可以试着修改./web/search_engine.py的第167行的关键词,看看搜索结果是否和你预想的排序是一样的。不过由于我们的数据量只有1000个新闻,并不能涵盖所有关键词,更多的测试可以留给大家线下完成。
    本文转载自:http://bitjoy.net/2016/01/07/introduction-to-building-a-search-engine-4
    1 回答 2019-06-01 15:29:45
  • 基于python构建搜索引擎系列——(三)构建索引

    目前正是所谓的“大数据”时代,数据量多到难以计数,怎样结构化的存储以便于分析计算,是当前的一大难题。上一篇博客我们简单抓取了1000个搜狐新闻数据,搜索的过程就是从这1000个新闻中找出和关键词相关的新闻来,那么怎样快速搜索呢,总不可能依次打开xml文件一个字一个字的找吧,这时就需要借助倒排索引这个强大的数据结构。
    在讲倒排索引之前,我们先介绍一下布尔检索。布尔检索只是简单返回包含某个关键词的文档,比如查询“苹果手机”,则返回所有包含“苹果”和“手机”关键词的文档,布尔检索并不对返回结果排序,所以有可能返回的第一个文档是“某个男孩边吃苹果边玩手机…“。
    实现布尔检索并不难,我们需要构建一个如下图的词项文档矩阵:

    每行对应一个词项,每列对应一个文档,如果该值为1,表示该行词项出现在该列文档中。比如词项”苹果“出现在doc1和doc3文档中,如果我们要找同时出现”苹果“和”手机“的文档,只需把他们对应的向量取出来进行”与“操作,此为101&011=001,所以doc3同时出现了”苹果“和”手机“两个关键词,我们将其返回。
    布尔检索虽然很快,但是它也有很多缺陷,比如不能对结果排序,词项只有出现和不出现两种状态,但是一篇文档中出现10次“苹果“和只出现1次”苹果“,他们的相关度肯定是不相同的。所以需要对布尔检索进行改进。
    在扫描文档时,不但记录某词项出现与否,还记录该词项出现的次数,即词项频率(tf);同时我们记录该文档的长度(ld),以及某词项在不同文档中出现的次数,即文档频率(df)。

    这样我们就得到了如上图的倒排索引。左边部分被称为词典,存储的是1000个新闻中所有不同的词项;右边部分被称为倒排记录表,存储的是出现Term_i的那些文档信息。倒排索引中存储的变量都是为了给后续检索模型使用。
    讲到这里,我们需要解决如下几个问题。

    怎样得到一篇文档中的所有词项。给我们一篇新闻稿子,人类很容易分辨出”苹果“和”手机“是两个不同的词项,但是计算机怎么知道是这两个词呢?为什么不是”苹”、”国手“和”机“呢?这就需要进行中文分词,我们可以借助开源的jieba中文分词组件来完成,jieba分词能够将一个中文句子切成一个个词项,这样我们就可以统计tf, df了。
    有些词,如”的“、”地“、”得“、”如果“等,几乎每篇文档都会出现,他们起不到很好的区分文档的效果,这类词被称为”停用词“,我们需要把他们去掉。去停词的步骤可以在jieba分词之后完成。
    怎样存储倒排记录表。假设1000个文档共有20000个不同的词项,如果用类似图1的矩阵形式存储,需要耗费100020000=210^7个存储单元,但是图1往往是一个稀疏矩阵,因为一个文档中可能只出现了200个不同的词项,剩余的19800个词项都是空的。用矩阵方式存储时空效率都不高。所以我们可以采用图2的方式,词典用B-树或hash存储,倒排记录表用邻接链表存储方式,这样能大大减少存储空间。如果我们要将图2保存到数据库,可以对倒排记录表序列化成一个长的字符串,写入到一个单元格,读取的时候再反序列化。比如每个Doc内部用’\t’连接,Doc之间用’\n’连接,读取的时候split即可。

    倒排索引构建算法使用内存式单遍扫描索引构建方法(SPIMI),其实就是依次对每篇新闻进行分词,如果出现新的词项则插入到词典中,否则将该文档的信息追加到词项对应的倒排记录表中。SPIMI的伪代码如下:
    SPIMI-Invert(token_stream) output_file = NEWFILE() dictionary = NEWHASH() while(free memory available) do token ← next(token_stream) // 即不在词典中 if term(token) !∈dictionary // 加入词典并返回词典位置 then postings_list = AddToDictionary(dictionary, term(token)) // 找到词典位置 else postings_list = GetPostingList(dictionary, term(token)) // 关键词对应存储倒排文档的数据结构可用空间是否已满 if full(postings_list) // 重新分配关键词对应存储倒排文档的数据结构可用空间 使其变为原来2倍 then postings_list = DoublePostingList(dictinary, term(token)) // 将文档信息放入倒排表中 AddToPostingsList(postings_list,docId(token))//对词典排序(便于以后合并词典)sorted_terms ← SortTerms(dictionary)// 将倒排信息写入磁盘WriteBlockToDisk(sorted_terms, dictionary, output_file)return output_file
    下面是构建索引的所有代码:
    from os import listdirimport xml.etree.ElementTree as ETimport jiebaimport sqlite3import configparserclass Doc: docid = 0 date_time = '' tf = 0 ld = 0 def __init__(self, docid, date_time, tf, ld): self.docid = docid self.date_time = date_time self.tf = tf self.ld = ld def __repr__(self): return(str(self.docid) + '\t' + self.date_time + '\t' + str(self.tf) + '\t' + str(self.ld)) def __str__(self): return(str(self.docid) + '\t' + self.date_time + '\t' + str(self.tf) + '\t' + str(self.ld))class IndexModule: stop_words = set() postings_lists = {} config_path = '' config_encoding = '' def __init__(self, config_path, config_encoding): self.config_path = config_path self.config_encoding = config_encoding config = configparser.ConfigParser() config.read(config_path, config_encoding) f = open(config['DEFAULT']['stop_words_path'], encoding = config['DEFAULT']['stop_words_encoding']) words = f.read() self.stop_words = set(words.split('\n')) def is_number(self, s): try: float(s) return True except ValueError: return False def clean_list(self, seg_list): cleaned_dict = {} n = 0 for i in seg_list: i = i.strip().lower() if i != '' and not self.is_number(i) and i not in self.stop_words: n = n + 1 if i in cleaned_dict: cleaned_dict[i] = cleaned_dict[i] + 1 else: cleaned_dict[i] = 1 return n, cleaned_dict def write_postings_to_db(self, db_path): conn = sqlite3.connect(db_path) c = conn.cursor() c.execute('''DROP TABLE IF EXISTS postings''') c.execute('''CREATE TABLE postings (term TEXT PRIMARY KEY, df INTEGER, docs TEXT)''') for key, value in self.postings_lists.items(): doc_list = '\n'.join(map(str,value[1])) t = (key, value[0], doc_list) c.execute("INSERT INTO postings VALUES (?, ?, ?)", t) conn.commit() conn.close() def construct_postings_lists(self): config = configparser.ConfigParser() config.read(self.config_path, self.config_encoding) files = listdir(config['DEFAULT']['doc_dir_path']) AVG_L = 0 for i in files: root = ET.parse(config['DEFAULT']['doc_dir_path'] + i).getroot() title = root.find('title').text body = root.find('body').text docid = int(root.find('id').text) date_time = root.find('datetime').text seg_list = jieba.lcut(title + '。' + body, cut_all=False) ld, cleaned_dict = self.clean_list(seg_list) AVG_L = AVG_L + ld for key, value in cleaned_dict.items(): d = Doc(docid, date_time, value, ld) if key in self.postings_lists: self.postings_lists[key][0] = self.postings_lists[key][0] + 1 # df++ self.postings_lists[key][1].append(d) else: self.postings_lists[key] = [1, [d]] # [df, [Doc]] AVG_L = AVG_L / len(files) config.set('DEFAULT', 'N', str(len(files))) config.set('DEFAULT', 'avg_l', str(AVG_L)) with open(self.config_path, 'w', encoding = self.config_encoding) as configfile: config.write(configfile) self.write_postings_to_db(config['DEFAULT']['db_path'])if __name__ == "__main__": im = IndexModule('../config.ini', 'utf-8') im.construct_postings_lists()
    运行之后会在./data/下生成一个ir.db数据库文件,这就是构建好的索引数据库。
    本文转载自:http://bitjoy.net/2016/01/07/introduction-to-building-a-search-engine-3
    1 回答 2019-05-30 14:51:46
  • 基于python构建搜索引擎系列——(二)网络爬虫

    网络爬虫又称网络蜘蛛、Web采集器等,它是一种按照一定的规则,自动地抓取万维网信息的程序或者脚本。
    我们在设计网络爬虫的时候需要注意两点:

    鲁棒性:Web中有些服务器会制造采集器陷阱(spider traps),这些陷阱服务器实际上是Web页面的生成器,它能在某个域下生成无数网页,从而使采集器陷入到一个无限的采集循环中去。采集器必须能从这些陷阱中跳出来。当然,这些陷阱倒不一定都是恶意的,有时可能是网站设计疏忽所导致的结果
    礼貌性:Web服务器具有一些隐式或显式的政策来控制采集器访问它们的频率。设计采集器时必须要遵守这些代表礼貌性的访问政策

    采集器的基本架构如下图所示。

    基本上是”抓取→分析→得到新的URL→再抓取→再分析”这样一个死循环过程。
    以上内容摘自王斌老师翻译的《信息检索导论》课本。
    由于我们要做的是一个新闻搜索引擎,所以抓取的是新闻数据,对于爬虫,网上也有很多的开源程序,如nutch等,Github上还有人专门开发了抓取新闻的组件newspaper,可以很方便的提取新闻标题、正文、时间等信息。不过用python写爬虫也是分分钟的事情,下面我们一起来试一试。
    首先找一个新闻网站,为简单起见,要找那种结构清晰、html代码便于解析的门户网站,比如搜狐新闻、参考消息等。
    搜狐新闻的国内要闻列表如下:

    结构非常清楚,左边是带URL的标题,右边括号里有新闻时间。这一页列表就有200条新闻,如果我们要获取1000条,只要不断模拟点击下一页即可。下一页的URL也只是在首页的基础上加上_xxx.shtml,xxx就是不同的页码。
    查看列表的html源码,得知列表都在类名为newsblue1的td中,所以只需要解析html源码就可以得到新闻标题、URL和时间,python解析html可以用BeautifulSoup包,非常方便。
    进入到新闻详细页面,正文部分如下:

    查看html源码,正文位于类名为text clear的div中,据此可以很方便的提取新闻正文。
    得到一条新闻的所有数据之后,我们需要将之结构化成xml文件,借助相应的xml包可以很方便的完成这项工作。xml格式定义如下:
    <?xml version="1.0" encoding="utf-8"?><doc> <id></id> <url></url> <title></title> <datetime></datetime> <body></body></doc>
    注意爬虫需要访问网络,难免会出现一些异常,所以捕获异常是非常有必要的。另外,搜狐每篇新闻正文后面都会有一段’//‘开始的注释,这个需要过滤掉,短于140个字的新闻我也过滤掉了。整个搜索系统的配置参数都存储在config.ini文件中。
    下面是完整的python 3.4+代码:
    from bs4 import BeautifulSoupimport urllib.requestimport xml.etree.ElementTree as ETimport configparserdef get_news_pool(root, start, end): news_pool = [] for i in range(start,end,-1): page_url = '' if i != start: page_url = root +'_%d.shtml'%(i) else: page_url = root + '.shtml' try: response = urllib.request.urlopen(page_url) except Exception as e: print("-----%s: %s-----"%(type(e), page_url)) continue html = response.read() soup = BeautifulSoup(html) td = soup.find('td', class_ = "newsblue1") a = td.find_all('a') span = td.find_all('span') for i in range(len(a)): date_time = span[i].string url = a[i].get('href') title = a[i].string news_info = ['2016-'+date_time[1:3]+'-'+date_time[4:-1]+':00',url,title] news_pool.append(news_info) return(news_pool)def crawl_news(news_pool, min_body_len, doc_dir_path, doc_encoding): i = 1 for news in news_pool: try: response = urllib.request.urlopen(news[1]) except Exception as e: print("-----%s: %s-----"%(type(e), news[1])) continue html = response.read() soup = BeautifulSoup(html) try: body = soup.find('div', class_ = "text clear").find('div').get_text() except Exception as e: print("-----%s: %s-----"%(type(e), news[1])) continue if '//' in body: body = body[:body.index('//')] body = body.replace(" ", "") if len(body) <= min_body_len: continue doc = ET.Element("doc") ET.SubElement(doc, "id").text = "%d"%(i) ET.SubElement(doc, "url").text = news[1] ET.SubElement(doc, "title").text = news[2] ET.SubElement(doc, "datetime").text = news[0] ET.SubElement(doc, "body").text = body tree = ET.ElementTree(doc) tree.write(doc_dir_path + "%d.xml"%(i), encoding = doc_encoding, xml_declaration = True) i += 1if __name__ == '__main__': config = configparser.ConfigParser() config.read('../config.ini', 'utf-8') root = 'http://news.sohu.com/1/0903/61/subject212846158' news_pool = get_news_pool(root, 854, 849) crawl_news(news_pool, 140, config['DEFAULT']['doc_dir_path'], config['DEFAULT']['doc_encoding']) print('done!')
    本文转载自:http://bitjoy.net/2016/01/04/introduction-to-building-a-search-engine-2
    1 回答 2019-05-28 20:16:43
  • 基于python构建搜索引擎系列——(一)简介

    我们上网用得最多的一项服务应该是搜索,不管大事小情,都喜欢百度一下或谷歌一下,那么百度和谷歌是怎样从浩瀚的网络世界中快速找到你想要的信息呢,这就是搜索引擎的艺术,属于信息检索的范畴。
    这学期学习了《现代信息检索》课程,使用的是Stanford的教材Introduction to Information Retrieval,网上有电子版,大家可以参考。
    本课程的大作业是完成一个新闻搜索引擎,要求是这样的:定向采集3-4个新闻网站,实现这些网站信息的抽取、索引和检索。网页数目不少于10万条。能按相关度、时间和热度(需要自己定义)进行排序,能实现相似新闻的自动聚类。
    截止日期12月31日,我们已经在规定时间完成了该系统,自认为检索效果不错,所以在此把过程记录如下,欢迎讨论。
    网上有很多开源的全文搜索引擎,比如Lucene、Sphinx、Whoosh等,都提供了很好的API,开发者只需要调用相关接口就可以实现一个全功能的搜索引擎。不过既然学习了IR这门课,自然要把相关技术实践一下,所以我们打算自己实现一个搜索引擎。
    这是简介部分,主要介绍整个搜索引擎的思路和框架。

    上图为本搜索引擎的框架图。首先爬虫程序从特定的几个新闻网站抓取新闻数据,然后过滤网页中的图片、视频、广告等无关元素,抽取新闻的主体内容,得到结构化的xml数据。然后一方面使用内存式单遍扫描索引构建方法(SPIMI)构建倒排索引,供检索模型使用;另一方面根据向量空间模型计算两两新闻之间的余弦相似度,供推荐模块使用。最后利用概率检索模型中的BM25公式计算给定关键词下的文档相关性评分,BM25打分结合时间因素得到热度评分,根据评分给出排序结果。
    在后续博文中,我会详细介绍每个部分的实现。
    使用方法
    安装python 3.4+环境
    安装lxml html解析器,命令为pip install lxml
    安装jieba分词组件,命令为pip install jieba
    安装Flask Web框架,命令为pip install Flask
    进入web文件夹,运行main.py文件
    打开浏览器,访问 http://127.0.0.1:5000 输入关键词开始测试

    如果想抓取最新新闻数据并构建索引,一键运行./code/setup.py,再按上面的方法测试。
    本文转载自:http://bitjoy.net/2016/01/04/introduction-to-building-a-search-engine-1
    1 回答 2019-05-26 11:34:24
  • 基于Python实现的新闻网络爬虫程序

    1、简介1.1 引用术语与缩写解释


    缩写、术语
    解 释




    Python
    一种简洁而强大的解释型脚本语言


    pyodbc
    Python下的ODBC数据库访问组件


    SQLAlchemy
    Python下的ORM数据访问组件


    pywin32
    Python下的Win32接口访问组件


    requests
    Python下的Web访问组件


    Pillow
    Python下的图像处理组件


    解释型语言
    无需编译源码可敏捷部署并执行的语言


    IOC
    控制反转,运行时可由数据反向干涉程序执行逻辑的设计模式


    RegularExpression
    正则表达式,查找和替换文本模式的简洁而灵活的表示法


    XML
    扩展标记语言,用于配置及数据交互



    1.2 概要本文档针对以下三个方面进行了详细说明:

    架构说明,对新闻网络爬虫的核心架构进行描述,供开发人员在开发或升级应用时参考
    部署说明,对新闻网络爬虫的部署步骤进行描述,供部署人员进行应用部署或升级时参考
    扩展说明,对新闻网络爬虫的扩展模式进行描述,供开发人员扩展爬虫功能时参考

    1.3 应用设计目标
    易于扩展
    易用
    易于维护

    为了达成这些设计目标我们采用了以下设计机制:

    易于扩展

    使用解释型、面向对象的Python语言实现程序逻辑:解释型语言易于扩展部署,结合抓取模块的IOC机制,更新升级时无需停机;面向对象易于代码复用,创建新爬虫模块时仅需少量重载代码修改控制流程IOC化,利用XML配置文件,可动态增减爬虫任务、控制爬虫任务启动条件、更改爬虫规则以及爬虫逻辑
    易用

    服务化,爬虫任务被封装为Windows 服务,可便捷地启动与停止,于后台执行长期运行
    易于维护

    编程语言简洁,缩进式风格减少了语法性代码,动态数据类型减少了声明性代码,函数式编程特性减少了重复性函数代码,友好而功能强大的标准库与外部组件也减少逻辑复杂性数据访问层模型化:使用SQLAlchemy组件,对数据访问进行对象化建模,屏蔽数据访问细节,如SQL,数据模型易于维护

    2、架构说明新闻网络爬虫程序主题为一系列python脚本,通过文件夹进行模块隔离,基于命名约定进行动态逻辑加载,根目录为ArticleSpider。
    整体框架如下:


    SpiderService.py:服务入口模块,用以处理Windows服务Article Spider Service的安装、卸载、启动、停止与重启
    SpiderTask.py:任务管理模块,负责加载控制规则配置、安排爬虫任务计划、组合爬虫任务子逻辑
    ArticleStorer.py:文章转存模块,包含数据库访问、图片转存与切图、队列消息发送功能
    RuleReader.py:规则读取模块,用于读取爬虫规则,辅助IOC机制
    Spider:爬虫逻辑模块,核心模块群,可根据需要添加新爬虫模板,爬虫模板可继承,基模块为Spider.py,多个相似爬虫可根据规则设置复用同一个爬虫模板
    Model:数据模型模块,维护爬虫相关ORM数据模型,由上下文管理层、数据模型层与事务逻辑层组成
    Message:消息处理模块,主要负责封装与发送队列消息
    SpiderRule.xml:爬虫规则配置,XML格式元数据
    Temp:缓存目录,用以缓存转存完成前的中间文件,如下载图片
    Log:日志目录,用以保存日志,采用循环日志模式
    ServiceCommand.txt:服务入口命令,用于参考的爬虫服务基本人机交互命令
    SpiderTest.py:爬虫测试模块,用于测试的相关脚本

    2.1 模块说明2.1.1 服务入口层 SpiderService.py2.1.1.1 SpiderService
    win32serviceutil.ServiceFramework:服务基类,引入自pywin32组件,用以将Python程序封装位Windows服务
    SvcDoRun:服务启动入口,重载基类方法,用以开启SpiderTask任务管理线程,阻塞等待任务结束事件
    SvcStop:服务终止入口,重载基类方法,用以终止SpiderTask任务管理线程,发起任务结束事件

    2.1.1.2 ServiceCommand
    python SpiderService.py install:爬虫服务安装,必须指令,用以注册Windows服务,安装成功后可直接于Windows服务管理器中进行服务启动、停止等管理操作
    python SpiderService.py install —startup auto:自启动爬虫服务安装
    python SpiderService.py start:启动爬虫服务,服务安装后有效
    python SpiderService.py restart:重启爬虫服务,服务启动后有效
    python SpiderService.py stop:停止爬虫服务,服务启动后有效
    python SpiderService.py remove:删除/卸载爬虫服务,服务安装后有效

    2.1.2 任务管理层 SpiderTask.py2.1.2.1 SpiderTask
    threading.Thread:线程管理基类,引入自python标准库,用以将主任务进行线程化封装
    __init__:初始化方法,进行一系列任务初始化操作,如线程初始化、日志初始化、调度初始化、存储初始化等操作
    ScheduleTask:任务调度方法,读取爬虫规则,根据设置生成爬虫调度计划
    RunSpiderByRule:爬虫驱动方法,按照给定规则,驱动对应爬虫开启任务,核心步骤为,爬虫选用—文章抓取—图片转存—文章入库—后续处理(如压图与消息通知)
    run:任务子线程启动入口,重载基类方法,以日为周期进行调度-执行-休眠循环,苏醒后调度爬虫任务—按照调度计划处理任务(执行或等待)—计划完成后休眠至下一周期苏醒
    StopTask:任务终止方法,当前任务完成后终止任务计划,非强行中断任务子线程,若要强行中断,可直接结束主线程,任务子线程将自动中断

    2.1.3 规则读取层 RuleReader.py2.1.3.1 RuleReader
    __init__:初始化方法,确定规则读取模式(目前仅支持XML模式),模式有效时进行初始规则读取
    FreshRules:规则刷新方法,读取最新规则,默认以XML格式读取规则,若要采用其他方法(如数据库读取、Json接口读取等),可继承该基类后进行重载扩展

    2.1.3.2 GetRuleFromNode功能性函数,从XML节点中提取爬虫规则字典,属性及简单子节点直接提取入本级字典,复杂子节点递归提取为子级字典后加入本级字典。
    2.1.3.3 PrintNode调试用函数,用于打印XML节点数据,采用前序遍历法。
    2.1.3.4 SpiderRule爬虫规则字典(dict类型),存储爬虫规则参数,以福州旅游网爬虫规则为例:

    name:规则名称,通常以爬取来源站命名,如福州旅游网
    sourceId: 来源标识,参照文章来源枚举值,默认为0
    rule:子级明细规则字典
    url:来源Url,明细规则,如:http://lyj.fuzhou.gov.cn/lyjzwgk/lydt/index.htm
    reAbstract:文章摘要正则表达式,明细规则,扫描文章清单时使用,如:
    <li>.+?<span>\[(.+?)\].+?href="(.+?)".+?>(.+?)</a>.+?</li>
    reArticle:文章正文正则表达式,明细规则,扫描文章正文时使用,如:
    <div class="content-boxtext">(.+?)</div>\s*<div class="content-boxbottom">
    reImage:文章图片正则表达式,明细规则,扫描文章图片时使用,如:
    <IMG.+?src="(.+?)".+?/>
    module:爬虫模块名,明细规则,反射加载的爬虫模块名与类名,如FuZhouSpider
    regionId:目的地标识,明细规则,爬虫目的地对应的乐途目的地字典标识值,如130
    spiderName:爬虫编辑ID,明细规则,利用爬虫对内发布文章的虚拟编辑用户名,如jishutest
    isValid:有效标志,明细规则,启用该规则时为1,停用为0,禁用为-1
    minPage:最小页码,明细规则,分页爬取时第一页的页码参数,默认为-1(不分页)
    maxPage:最大页码,明细规则,分页爬取时最后页的页码参数,默认为-1(不分页)
    wakeTime:子级苏醒时间字典,明细规则,可包含多个时间点
    timePotX:时间点,X代表时间点标识,多个时间点标识不可相同,时、分、秒必须

    2.1.4 文章转存层 ArticleStorer.py2.1.4.1 ArticleStorer文章转存器,组织下层通信、数据模块进行数据交互,如数据库访问、队列消息发送、文件上传、远程接口调用等。

    __init__:初始化方法,设定图片转存API(imageApi)、数据库连接(dbConStr)、消息队列(msmqPath)及压图API(picCutApi)等通信参数,并进行初始化
    DumpImage:图片转存方法,转存文章正文中已缓存的的下载图片至图片转存API,同时关联转存图片路径
    DumpArticle:文章入库方法,将已经完成图片转存并替换Url的文章正文按照正文页入库,同时记录爬取关联信息
    NewArticleCheck:新文章检查方法,比对爬取关联信息,确定是否为新文章或可更新文章
    SendSuccessMessage:后期处理消息信息发送方法,向消息队列发送信息,通知后续处理程序处理刚刚发布或变更的新正文页
    CutImaages:正文页压图方法,调用压图接口对指定正文页正文内的图片进行压图处理
    ArticleRepublish:正文页重发布方法,将正文页重新发布,即向消息队连续发送一条删除发布请求与新发布请求

    2.1.5 文章爬虫层 Spider该层为目录,包含多个XSpider.py模块,其中Spider.py为基础模块,其他模块如FuZhouSpider.py为基于Spider.py模块的扩展的模板化模块。
    2.1.5.1 Spider爬虫基类,封装爬虫所需的基本操作,可由子类继承复用(如规则加载、HTML下载、图片下载等共同操作)或重载(文章抓取、图片替换等区别操作)。

    ReadRule:规则加载方法,可继承复用,可嵌套重载,加载爬虫规则字典内参数信息,并对爬虫伪装(Http Header)、数据缓存、数据清理(Css清理、Herf清理等)等参数进行设置
    CatchArticles:文章抓取方法,不可直接复用,必须替换重载,为各爬虫模板的独有逻辑,目前主要有页面抓取及异步JS抓取两种基本模式
    DownLoadHtml:Html源码下载方法,可继承复用,可重载(如进行拟人化访问伪装) ,用于获取抓取页面的相关Html源码,如文章列表页、文章正文页以及异步加载API
    DownLoadImage:图片下载方法,可继承复用,可重载(如应对图片防盗链机制),用于下载文章正文中的图片链接至缓存路径,同时进行图片格式标准化
    ReplaceArticleImages:缓存文章正文图片替换方法,可继承复用,可重载(如对转存图片标签添加属性),用于将抓取文章正文中原来的图片链接替换为转存后的图片链接
    CacheArticle:文章信息缓存方法,可继承复用,可重载(如定制文章特有属性信息),用于组装文章属性信息并加入缓存等待下一步处理
    ClearArticles:文章正文清洗方法,可继承复用,可重载(如添加JS清晰),用于清洗文章正文中的无效信息,如当前已支持的CSS、Herf、空白字符及样式清洗
    ClearTempImages:缓存图片清理方法,可继承复用,可重载(如添加缓存备份机制),用于清理缓存的已下载图片

    2.1.5.2 Functions文章爬虫层中的相关功能性函数。

    ReplaceImage:图像Url替换函数,可将文章正文中的正则匹配项(原始图片链接)替换为转存图片链接
    ClearExternalCss:CSS清理函数,可将文章正文中的正则匹配项(Css类)清空
    ClearExternalHerf:Herf清理函数,可将文章正文中的正则匹配项(超链接)去链接处理
    ClearExternalBlank:空白字符清理函数,可将文章正文中的正则匹配项(空白字符)去除
    ClearExternalStyle:样式清理函数,可将文章正文中的正则匹配项(style样式)去除
    ComposeUrl:相对Url组装函数,将页面中的相对Url结合页面Url组装为绝对Url
    ConvertImageToJpg:图片格式标准化函数,将不同格式的图片(如PNG、BMP)同意转化为JPG格式

    2.1.5.3 XSpider各种爬虫模板类,通常直接继承自Spider,但也可继承其他XSpider,区别主要在于CatchArticles方法的重载实现,目前主要分为两种模式。

    页面爬虫:CatchArticles方法直接解析页面源码,根据制定的编码格式,提取文章关键信息,由扫描列表页的文章摘要、扫描正文页的正文、扫描正文域的图片三步组成,典型模板如FuZhouSpider。
    Json Api爬虫:CatchArticles方法获取Json API响应,将结果反序列化为字典对象,提取文章关键信息,由提取列表API的文章摘要、提取正文API的正文与图片两步组成。,典型模板如FuZhouTourSpider。

    其他类型的模板可根据实际情况自行扩展,如XML API、SOAP API等。
    2.1.6 数据模型层 Model该层为目录,包含多个SpiderContext.py、SpiderEntity.py与SpiderData.py三个模块。

    SpiderContext.py:数据上下文模块,负责数据库连接、数据模型初始化、会话管理
    SpiderEntity.py:数据模型模块,负责数据实体对象模型映射,即表与类的映射
    SpiderData.py:数据逻辑模块,负责组织会话内的数据访问逻辑,也是对外接口

    2.1.6.1 SpiderDataHelper爬虫数据访问类,属于数据逻辑模块。

    SaveArticle:文章入库方法,将爬去并完成过滤的文章信息写入数据库,若是新文章,文章信息入库同时写入文章导入信息,若不是新文章(导入信息已存在),仅进行修改
    NewArticleCheck:新文章检查方法,用于防止文章重复导入,对比文章导入信息,若不匹配(根据文章Url与发布日期),则认定为新文章,否则认定为重复文章
    GetArticlesLikeDampSquibs:未成功发布文章扫面方法,用于查询出已发布但未成功的文章,返回必要属性信息,如正文页ID,目的地ID等

    2.1.6.2 ModelMapper实体关系映射(类表映射)函数,属于数据模型模块,将指定的实体类与数据表绑定,当前已映射对如下:

    Cms_Information—Cms_Information:正文页信息类与正文页信息表
    Cms_Information_Inported—Cms_Information_Inported:正文页导入信息类与正文页导入信息表
    Cms_InformationRegion—Cms_InformationRegion:正文页目的地关联类与正文页目的地关联表

    2.1.6.3 ModelContext数据模型上下文类,属于数据上下文模块,管理数据连接上下文。

    __init__:上下文初始化方法,注册数据库连接、初始化数据模型元数据并建立会话生成器
    Session:会话入口,申请一个数据库会话

    2.1.6.4 Session_Scope数据会话域函数,属于数据上下文模块,负责隔离会话事务的生命周期,具有contextmanager特性,即以迭代生成器的方式隔离会话事务逻辑无关的细节,确保成功自动提交、失败自动回滚并且结束自动释放。
    2.1.7 消息模型层 Message该层为目录,包含SpiderMessageQueue.py模块,负责格式化消息,并与消息队列通信。
    2.1.7.1 Message消息类,负责消息格式化,将消息转化为制定输出格式。

    __init__:初始化方法,将消息字典初始化为XML结构,同时添加必要属性
    ToFormatString:序列化方法,将XML结构的消息转化为字符串,同时添加必要说明性内容

    2.1.7.2 ToXmlElement功能性函数,将字典转化为XML结点格式,简单键值对直接转化为子节点,复杂键值对进行嵌套转化,值数组转化为多个子节点,值为字典则进行递归转化。
    2.1.7.3 MessageQueue消息队列访问类,封装消息队列接口。

    __init__:初始化方法,组装MSMQ队列基本信息,包含主机名与队列路径
    SendMessage:消息发送方法,根据给定MSMQ队列基本信息,创建会话并发送消息

    2.1.7.4 Queue_Scope队列会话域函数,负责隔离队列会话的生命周期,具有contextmanager特性,即以迭代生成器的方式隔离队列会话逻辑无关的细节,确保会话结束自动释放。
    3、部署说明3.1 运行环境
    PC配置

    2G以上内存
    操作系统

    Windows XP Professional 2002 Service Pack 3+
    相关工具

    Python 3.3 Pyodbc 3.0.7 SQLAlchemy 0.9.7pywin32 219requests 2.4.1Miscrosoft Message QueueMiscrosoft SQL Server 2005SQL Server Native Client 10.0

    3.2 资源目录
    源码路径,192.168.80.157主机 E:\shuaxin\ArticleSpider。
    工具路径:192.168.80.157主机 E:\tools\爬虫项目部署包

    3.3 部署步骤3.3.1 Python确认部署主机python-3.x版本已安装,建议使用python-3.3稳定版本。
    若未安装,执行爬虫项目部署包内python-3.3.5.1395976247.msi文件,以安装python虚拟机,安装目录可设置为E:\tools\Python33。
    确保系统环境路径(高级系统设置-环境变量)含有python安装目录,确保路径内可引用python.exe。
    3.3.2 MSMQ确保Miscrosoft Message Queue已开启,且存在消息队列路径\private$\queuepathnews,且该队列对EveryOne开放消息发送权限,若该队列不存在,则依赖部署条件不满足(后续处理程序未部署),不可进行应用部署。
    3.3.3 DataSource Driver确保主机ODBC数据源中已安装SQL Server Native Client 10.0驱动程序(版本可以更高,但ArticleStorer.py中dbConStr也应对应修改)已正确安装。
    若未安装,根据系统环境,选择执行爬虫项目部署包内sqlncli_X86.msi或sqlncli_X64.msi,以安装数据源驱动。
    3.3.4 Pyodbc确保python安装目录(如E:\tools\Python33)下,存在以下相对路径的目录Lib\site-packages\pyodbc…,即Pyodbc已安装。
    若未安装,根据系统环境,执行爬虫项目部署包内pyodbc-3.0.7.win32-py3.3.exe,以安装Python ODBC组件。
    3.3.5 Pywin32确保python安装目录(如E:\tools\Python33)下,存在以下相对路径的目录Lib\site-packages\pythonwin,即Pywin32已安装。
    若未安装,根据系统环境,执行爬虫项目部署包内pywin32-219.win32-py3.3.exe,以安装Python Win32组件。
    3.3.6 Pillow确保python安装目录(如E:\tools\Python33)下,存在以下相对路径的目录Lib\site-packages\PIL,即Pillow已安装。
    若未安装,根据系统环境,执行爬虫项目部署包内Pillow-2.6.0.win32-py3.3.exe,以安装Python Image Library组件。
    3.3.7 Requests确保python安装目录(如E:\tools\Python33)下,存在以下相对路径的目录Lib\site-packages\requests,即Requests已安装。
    若未安装,启动cmd,切换至爬虫项目部署包下requests-2.4.1目录,执行命令python setup.py install,以安装Python Requests组件。
    3.3.8 SQLAlchemy确保python安装目录(如E:\tools\Python33)下,存在以下相对路径的目录Lib\site-packages\sqlalchemy,即SQLAlchemy已安装。
    若未安装,启动cmd,切换至爬虫项目部署包下SQLAlchemy-0.9.7目录,执行命令python setup.py install,以安装Python SQLAlchemy组件。
    3.3.9 SQL Server确保81主机上的lotour库,已存在Cms_Information_Inported表。
    若不存在,执行初始化脚本:
    CREATE TABLE [dbo].[Cms_Information_Inported] ( [SourceUrl] VARCHAR (256) NOT NULL, [Status] SMALLINT NOT NULL, [InformationId] INT NOT NULL, [SourceTime] DATETIME NOT NULL, [RegionId] INT NOT NULL, [SourceName] VARCHAR(50) NULL, PRIMARY KEY CLUSTERED ([SourceUrl] ASC));CREATE NONCLUSTERED INDEX [IX_Cms_Information_Inported_InformationId]ON [dbo].[Cms_Information_Inported]([InformationId] ASC);
    3.3.10 Spider Service检查服务管理程序中,是否存在Article Spider Service服务,即爬虫服务是否已安装。若未安装,选定部署路径,将ArticleSpider目录移动至对应路径,启动cmd,切换至部署目录,执行命令python SpiderService.py install,以安装爬虫服务。
    应用扩展升级时无须重新安装,除非变更SpiderTask.py,亦无须停止Article Spider Service服务,直接添加或替换对应源文件即可(次日生效),但重启服务可确保变更立即生效。
    进入服务管理程序,启动Article Spider Service,若启动成功,整个部署流程完成。
    3.4 配置参数3.4.1 图片转存APIArticleStorer.py中的imageApi参数,指向CMS正文页的图片上传接口,默认为’http:// localhost:8037/WS/newsUploadImage.ashx’。
    3.4.2 数据库连接ArticleStorer.py中的dbConStr参数,对应Lotour库的数据连接字符串,默认为:
    mssql+pyodbc:// testUser:test@localhost:1433/news?driver=SQL Server Native Client 10.0其中mssql表示DBMS为Microsoft Sql Server,pyodbc表示驱动模式为Python ODBC,testUser为数据库用户名,test为用户密码,@至?间区域表示数据库路径,?后区域表示数据库驱动名称。
    3.4.3 消息队列ArticleStorer.py中的msmqPath参数,指向CMS正文页的发布消息队列,默认为:\PRIVATE$\queuepathnews。
    3.4.4 压图APIArticleStorer.py中的picCutApi参数,指向CMS正文页的压图接口,默认为: http://cms.lotour.com:8343/WS/newsCutImage.ashx 。
    3.4.5 图片缓存路径Spider.py中的temp参数,指向文章爬取过程中下载中间图片的缓存目录,默认为相对路径’\temp\‘。
    3.4.6 日志设置SpiderTask.py中的logging.basicConfig函数的相关入口参数,作为爬虫日志设置,可参考python官方文档中的logging部分,当前使用循环日志,部分参数如下:

    filename,日志文件名,默认使用相对路径’\Log\spider.log’
    mode,文件使用模式,默认使用添加模式,即’a’
    maxBytes,循环日志块大小,默认为2M
    backupCount,循环日志块数量,默认为8
    encoding,日志编码,默认为’utf-8’
    format,日志信息格式,用于将Trace信息与Message信息格式化,默认为:
    %(asctime)s %(levelname)-10s[%(filename)s:%(lineno)d(%(funcName)s)] %(message)slevel,日志记录级别,默认为DEBUG,线上部署推荐使用WARN或ERROR

    3.4.7 爬虫规则路径SpiderTask.py中的rulePath参数,默认为XML文件路径,默认值为相对路径’\SpiderRule.xml’。
    4、扩展说明4.1 扩展范围网络爬虫服务扩展分为三个级别,分别为规则扩展、模板扩展以及功能扩展,以应对不同的扩展需求。

    规则扩展:改动规则文件,即SpiderRule.xml,用于爬虫规则微调,以及添加仅需复用爬虫模板的新爬虫(如同站新频道,或同网站架构)
    模板扩展:新增爬虫实现,即添加XSpider.py,继承Spider基类,重载实现特有逻辑(如文章抓取逻辑),必要时可单独添加独立功能,如防盗链破解功能
    功能扩展:变更或重组爬虫功能,任何文件都可能改动,请在理解架构的前提下进行,如将规则元数据由XML元数据变更为可维护更大规模元数据的数据库元数据表,或者将爬虫服务重组以添加文章智能过滤层

    4.2 扩展示例4.2.1 规则扩展规则扩展仅需修改SpiderRule.xml文件,以福州旅游网爬虫为例:
    <rule name="福州旅游网" sourceId="0"><url>http://lyj.fuzhou.gov.cn/lyjzwgk/lydt/index.htm</url><reAbstract><li>.+?<span>\[(.+?)\].+?href="(.+?)".+?>(.+?)</a>.+?</li></reAbstract> <reArticle><div class="content-boxtext">(.+?)</div>\s*<div class="content-boxbottom"></reArticle> <reImage><IMG.+?src="(.+?)".+?/></reImage> <module>FuZhouSpider</module> <regionId>130</regionId> <spiderName></spiderName> <isValid>1</isValid> <minPage>-1</minPage> <maxPage>-1</maxPage> <wakeTime> <timePot0>08:00:00</timePot0> <timePot1>13:00:00</timePot1> </wakeTime></rule>
    一个Rule节点便是一项爬虫规则,结点属性及子节点含义参照3.1.3.4。
    每增加一个爬虫任务,则需要添加对应的Rule节点,添加流程如下:
    4.2.1.1 确定文章来源
    name:根据抓取需求,将name设置为[站名][-频道名]
    sourceId:进入CMS媒体管理模块,检查媒体来源,若不存在现有对应媒体来源,进行添加操作,将sourceId设置为对应媒体来源ID,若不指定媒体来源,忽略sourceId属性或置0
    url:观察抓取url对应源代码,若页面含有文章列表,即可按照网页抓取模式处理,url可直接设置为页面url;若列表内容为异步加载(可参考福州旅游资讯网),此时应分析源代码,找到对应的文章列表API,将url设置为对应API;若url中含有动态参数或分页参数,可使用{n}参数顺序替代(从{0}开始),在抓取逻辑中填入参数
    minPage & maxPage:若需求抓取页面不只一页,则将minPage置为起始页,maxPage置为终止页,同时定制扩展功能,利用参数化的url在抓取逻辑中进行处理
    regionId:抓取源应指定目的地,通常以乐途目的地字典中的ID值配置regionId即可,若有特殊情况(如混合目的地抓取),应在抓取逻辑中提取指定

    4.2.1.2 确定正则表达式
    reAbstract:若文章摘要为页面抓取模式,应分析页面源码,抽象出提取文章摘要信息的正则表达式,其中文章链接必须提取,文章标题与发布时间则是能提取尽量提取,将正则表达式进行html转义处理后置入reAbstract(参考在线转义工具如http://www.cnblogs.com/relucent/p/3314831.html )。若文章摘要为异步加载模式,reAbstract可按照抓取逻辑定制填写,正则表达式不必须,可空置或置为其他数据,如参数化文章正文API
    reArticle:若文章正文为页面抓取模式,应分析页面源码,抽象出提取文章正文信息的正则表达式,其中文章正文必须提取,文章标题与发布时间若在摘要中未提取也必须提取(无法提取时,应在抓取逻辑中另行处理)。若文章正文为异步加载模式,reArticle可按照抓取逻辑定制填写,正则表达式不必须,可空置或置为其他数据,如参数化图片API
    reImage:若图片隐藏在文章正文中,应分析正文代码,抽象出提取图片的正则表达式,图片链接必须提取。若图片信息独立于正文,reImage可按照抓取逻辑定制填写,正则表达式不必须,可空置或置为其他数据,如防盗链破解参数。

    4.2.1.3 确定爬虫模板module:不同规则的爬虫抓取逻辑可能各不相同,也可能存在复用,因此可在规则中指定爬虫模板,若无可复用模板,也可创建新爬虫模板,然后将module置为模板名称;模板名称格式通常为XSpider,不同模板名称不可重复,模板定义可参考模板扩展示例。
    4.2.1.4 确定任务计划
    isValid:爬虫规则可自由预设,但只有将isValid被置为1的规则,在爬虫任务计划扫描时进入任务队列,isValid置为0时表示停用,置为-1时表示禁用,其他取值可在功能扩展中确定
    spiderName:爬虫抓取文章后会发布正文页,默认会以匿名的乐途小编身份发表,此时spiderName置空,若要指定发布人,应将spiderName置为特定编辑者的CMS用户名
    wakeTime:爬虫任务若要执行,至少还需要一个执行时间,爬虫将在指定时间之后被唤醒,执行任务,此时应在wakeTime中添加timePotX子节点,X用于区别多个不同时间点(如0、1、2),意味着统一爬虫可在一日之内的不同时间启动多次

    4.2.2 模板扩展模板扩展需添加XSpider.py文件,在其中实现继承自Spider或其他模板Spider的XSpider类,其中CatchArticles必须重载以实现定制化的抓取逻辑,以福州福州旅游网爬虫为例:
    class FuZhouSpider(Spider.Spider): """福州旅游网 Spider""" def __init__(self): Spider.Spider.__init__(self) def CatchArticles(self): recAbstract = re.compile(self.reAbstract, re.DOTALL) recArticle = re.compile(self.reArticle, re.DOTALL) recImage = re.compile(self.reImage, re.DOTALL) html = self.DownLoadHtml(self.url, '文章列表页{0}访问失败,异常信息为:{1}') if html == None: return self.articles for x in recAbstract.findall(html): article = dict( time = datetime.datetime.strptime(x[0],'%Y-%m-%d'), # url = self.url[0:self.url.rfind('/')] + x[1][1:], url = Spider.ComposeUrl(self.url, x[1]), title = x[2] ) html = self.DownLoadHtml(article['url'], '文章页{0}访问失败,异常信息为:{1}') if html == None: continue content = None images = [] imageCount = 0 for y in recArticle.findall(html): content = y for z in recImage.findall(content): imageCount += 1 # imageUrl = article['url'][0:article['url'].rfind('/')] + z[1:] imageUrl = Spider.ComposeUrl(article['url'], z) image = self.DownLoadImage(imageUrl, '图片{0}提取失败,异常信息为:{1}') if image == None: continue images.append(image) if not content \ or imageCount != len(images): continue self.CacheArticle(article, content, images, '成功自{0}提取文章') return self.articles
    由于采用继承机制,__init__方法中应调用父级__init__方法,CatchArticles方法则可利用基类模板中的方法、字段及函数,结合自有方法、字段及函数扩展抓取逻辑,扩展流程如下:
    4.2.2.1 处理继承要素选定基类
    通常使用Spider.Spider,其中前一个Spider为模块名,对应Spider.py,后一个Spider为类名,如需更细粒度的模板复用,如定制页面抓取模板或异步抓取模板,可继承对应二级模板。
    选定重载方法
    继承模板通常会复用基类模板的大部分方法,但作为区别,必定有自身的特定逻辑,比如重载CatchArticles方法用以实现不同爬虫抓取逻辑(该方法在Spider中为虚方法,继承Spider后必须实现);
    除CatchArticles方法外,其他基类方法也存在扩展空间,可参考3.1.5.1中的方法说明进行重载。
    添加自有元素
    通常情况下,基类元素已经足够使用,但在一些特殊场景,可能需要添加一些新元素,比如文章过滤函数,图片抗反盗链下载之类的功能型函数,然后在重载的方法中使用。
    4.2.2.2 组织抓取逻辑抓取源加载
    根据爬虫规则中定义的url或者Api,从抓取源下载数据;
    页面抓取时,需指定指定抓取页面的编码格式,默认为utf-8编码,但部分页面会使用gbk或者gb2312编码;
    异步抓取或多页抓取时,API通常需要一些需要二次指定的参数,此时应将参数赋值再发起请求;
    抓取源通依照抓取模式存在区别,页面抓取源通常由三段式的文章列表页、文章正文页与文章正文组成,异步抓取源则随API的定义不同而不同,混合抓取则结合了前两者。
    基类抓取相关方法包括DownLoadHtml与DownLoadImage,DownLoadHtml用于加载Html源码或者Api响应数据,DownLoadImage则用于下载图片等文件至缓存路径。
    数据解析与提取
    抓取数据最终需转化为文章基本信息,用于后期处理,其中必要组成部分包含文章摘要信息(文章源链接、标题及发布日期等)、文章正文以及图片清单(图片链接及缓存路径等);
    页面抓取数据需要使用正则表达式解析,对应正则表达式由基类从爬虫规则读取,由reAbstract(文章摘要规则)、reArticle(文章正文规则)以及reImage(图片规则)组成,分别可提取信息至article(文章基本信息)、content(文章正文)以及images(图片列表);
    异步抓取通常不需要使用正则表达式,因为抓取数据通常为结构化数据,可以直接反序列化为友好的数据结构,如Json数据,可先进行清洗(将null替换为Python中的None),然后直接使用eval函数转化为数据字典;
    reAbstract、reArticle及reImage在异步抓取或混合抓取中的存储涵义开放,可配合模板自行定义,如API,筛选规则等,只需要保证最终可正确提取文章基本信息;
    提取文章发布时间时,应注意时间数据的表示形式,指定转化格式(如%Y-%m-%d可匹配2014-10-1),并注意从摘要数据和从正文数据提取的区别;
    正则解析函数主要使用findall进行全文搜索,注意该函数匹配结果为多项时将返回匹配结果迭代器,为单项时将直接返回匹配结果;
    信息提取完成后,应调用基类的CacheArticle方法,将article、content与images组装并缓存,等待后起批量处理。
    4.2.2.3 文章后期处理通常情况下图像后期处理对模板扩展透明,但其中的部分环节可进行独立定制,文章后期处理的流程为:
    有效性检查—图片转存—文章正文图片链接替换—正文清洗—文章入库—文章图片压图—发布。
    其中模板扩展可参与环节主要为文章正文图片链接替换(ReplaceArticleImages)与正文清洗(ClearArticles)环节。
    ReplaceArticleImages扩展示例如更改img标签的替换形式,如加入alt等属性。
    ClearArticles扩展示例如增加过滤规则,如JS过滤。
    4.2.3 功能扩展功能扩展自由度较大,但通常情况下是对爬虫服务任务流程进行重组。
    爬虫服务主流程为:
    扫描爬虫任务—执行爬虫任务—休眠,其功能扩展主要集中在任务管理层与规则读取层。
    特定规则的执行流程为:
    加载任务—文章抓取—有效性检查—图片转存—文章正文图片链接替换—正文清洗—文章入库—文章图片压图—发布,其功能扩展主要集中在文章转存层、数据模型层以及消息模型层。
    功能扩展的方式主要有两种,加入中间层以添加中间步骤以及重载替换特定步骤,对应的可扩展空间请参阅架构说明部分。
    1 回答 2019-05-02 11:18:20
  • 网络爬虫技术原理介绍

    什么是爬虫?网络爬虫是一种按照一定的规则,自动的爬取万维网信息的程序或者脚本。网络爬虫按照系统结构和实现技术,大致分为通用爬虫,聚焦爬虫,增量式爬虫,深层网络爬虫。但是实际应用中一般都是几种爬虫技术相结合实现的。
    搜索引擎其实是一种大型的复杂的网络爬虫属于通用爬虫的范畴。专业性质的搜索引擎则是属于聚焦爬虫的范畴。增量式爬虫则是对已下载的网页采取增量式更新和只爬取新产生的或者已经发生变化的网页的爬虫。Web网页按照存在的方式可以分为表层网页和深层网页,表层网页通常是指传统引擎可以索引的页面,以超链接可以到达的静态网页为主构成的Web页面;深层网络是那些大部分内容不能通过静态链接获取的、隐藏在搜索表单后的,只有用户提交一些关键词才能获取的Web页面。爬取深层网络页面的爬虫就属于深层网络爬虫的范畴。
    爬虫的工作流程

    首先选取一部分或者一个精心挑选的中字URL
    将这些URL放入带抓取的URL队列
    从待抓取的URL队列中读取待抓取的URL,解析DNS,并且得到主机的IP,并将URL对应的网页下载下来,存储到已下载的网页数据中。并且将这些URL放进已抓取的URL队列中
    分析已抓取URL队列中URL,从已下载的网页数据中分析出其他的URL,并和已抓取的URL进行去重处理,最后将去重后的URL放入待抓取URL队列,从而进入下一个循环

    爬虫的python实现框架Scrapy爬虫的python实现的库有很多,主要有urllib,httplib/urllib,requests.这里主要讲一下Scrapy框架。
    Scrapy使用python写的Crawler Framwork,简单轻巧,并且非常方便。Scrapy使用的是Twisted异步网络库来处理网络通信,架构清晰,并且包含了各种中间件接口,可以灵活的完成各种需求。


    引擎打开一个网站,找到处理改网站的spider并向该spider请求第一个要爬取的URL
    引擎从Spider中获取的第一个要爬取的URL并通过调度器(Scheduler)以Requests进行调度
    引擎向调度器(Scheduler)请求下一个要爬取的URL
    调度器(Scheduler)返回下一个要爬取的URL给引擎(Scrapy Engine),引擎将URL通过下载器中间件(Downloader Middleware)转发给下载器
    一旦页面下载完毕,下载器生成一个该页面的Response,并将其通过下载器中间件转发给引擎
    引擎从下载器中间件接收到Response并通过spider中间件发送给spider处理
    Spider处理Response并返回爬取到的Item和新的Request给引擎
    引擎将爬取到的item给Item Pipeline,将Request给调度器
    从第2步开始重复直到调度器中没有Request,引擎关闭该网站
    6 回答 2019-04-05 15:45:45
  • MUI H5文档笔记

    MUI H5文档笔记MUI H5文档笔记界面初始化H5plus初始化创建子页面打开界面参数传递控制页面load显示关闭界面底部导航切换界面自定义事件页面预加载消息框原生模式ActionSheet下拉刷新上拉加载上拉下拉整合手势遮罩滑动导航选择图片轮播自定义导航Ajax-get请求Ajax-post请求照相机访问相册蜂鸣提示音手机震动弹出菜单设备信息手机信息发送短信拨打电话发送邮件本地存储图片上传地理位置设置IOS状态栏手机通讯录启动页设置PHP后台搭建JSON转换隐藏本页面中滚动条首次启动欢迎页数据库增删改查和接口推送浏览器打开新页面PDF浏览自定义下拉刷新即时聊天双击安卓返回键退出QQ登录界面初始化初始化就是把一切程序设为默认状态,把没准备的准备好。mui框架将很多功能配置都集中在mui.init方法中,要使用某项功能,只需要在mui.init方法中完成对应参数配置即可,目前支持在mui.init方法中配置的功能包括:创建子页面、关闭页面、手势事件配置、预加载、下拉刷新、上拉加载。
    H5plus初始化在我们APP的开发中,如果我们用到了H5+的一些API或者接口,我们需要初始化另外一个函数,类属于 JS 中的window.onload 或者 window.ready
    Mui.plusReady(); 所有涉及到H5+的东西,建议写到这个里面
    mui.plusReady(function(){ var w = plus.webview.currentWebview(); console.log(w); });
    创建子页面为防止APP运行过程中内容滚动出现卡顿的现象,所以部分页面我们采用头部和内容分离的形式进行实现,比如头部导航和底部导航
    mui.init({ subpages:[{ url:your-subpage-url,//子页面HTML地址,支持本地地址和网络地址 id:your-subpage-id,//子页面标志 styles:{ top:subpage-top-position,//子页面顶部位置 bottom:subpage-bottom-position,//子页面底部位置 width:subpage-width,//子页面宽度,默认为100% height:subpage-height,//子页面高度,默认为100% ...... }, extras:{ name:'zxd学院'//子页面通过plus.webview.currentWebview().name能拿到这个值 }//额外扩展参数 }] });
    打开界面//打开新窗口mui.openWindow({ url:'target.html', //需要打开页面的url地址 id:'target', //需要打开页面的url页面id styles:{ top:'0px',//新页面顶部位置 bottom:'0px',//新页面底部位置 // width:newpage-width,//新页面宽度,默认为100% // height:newpage-height,//新页面高度,默认为100% ...... }, extras:{ name:'aries', age:'18',// .....//自定义扩展参数,可以用来处理页面间传值 },show:{ //控制打开页面的类型 autoShow:true,// //页面loaded事件发生后自动显示,默认为true aniShow:'zoom-fade-out',//页面显示动画,默认为”slide-in-right“; 页面出现的方式 左右上下 duration:'1000'//页面动画持续时间,Android平台默认100毫秒,iOS平台默认200毫秒; }, waiting:{ // 控制 弹出转圈框的信息 autoShow:true,//自动显示等待框,默认为true title:'WRITE-BUG...',//等待对话框上显示的提示内容 options:{ width:'300px',//等待框背景区域宽度,默认根据内容自动计算合适宽度 height:'300px',//等待框背景区域高度,默认根据内容自动计算合适高度 ...... } }});
    参数传递mui.plusReady(function(){ var self = plus.webview.currentWebview(); //获得当前页面的对象 var name = self.name; //name 和 age 为传递的参数的键 var age = self.age; console.log(name); console.log(age); // 获得首页 专用的 var index = plus.webview.getLaunchWebview(); // 获得指定页面的对象 注意,要确保你的这个页面是存在的, 就是打开过的 var target = plus.webview.getWebviewById('目标页面的id');});
    控制页面load显示show:{ // openwindow 函数内设置 autoShow:false } // 目标页面//从服务器获取数据 .... 这里是业务逻辑//业务数据获取完毕,并已插入当前页面DOM; //注意:若为ajax请求,则需将如下代码放在处理完ajax响应数据之后;mui.plusReady(function(){ //关闭等待框 plus.nativeUI.closeWaiting(); //显示当前页面 mui.currentWebview.show(); });
    关闭界面
    点击包含.mui-action-back类的控件
    在页面上,向右快速滑动
    Android手机按下back按键

    mui框架封装的页面右滑关闭功能,默认未启用,若要使用右滑关闭功能,需要在mui.init();方法中设置swipeBack参数,如下:
    mui.init({ swipeBack:true //启用右滑关闭功能});
    mui框架默认会监听Android手机的back按键,然后执行页面关闭逻辑; 若不希望mui自动处理back按键,可通过如下方式关闭mui的back按键监听:
    mui.init({ keyEventBind: { backbutton: false //关闭back按键监听 }});
    底部导航切换界面HTML部分:
    <nav class="mui-bar mui-bar-tab"> <a id="defaultTab" class="mui-tab-item mui-active" href="a.html"> <span class="mui-icon mui-icon-videocam"></span> <span class="mui-tab-label">社区</span> </a> <a class="mui-tab-item" href="b.html"> <span class="mui-icon mui-icon-chatboxes"><span style="display: none;" class="mui-badge">1</span></span> <span class="mui-tab-label">群组</span> </a> <a class="mui-tab-item" href="c.html"> <span class="mui-icon mui-icon-home"></span> <span class="mui-tab-label">我的</span> </a></nav>
    JavaScript部分:
    //mui初始化mui.init();var subpages = ['a.html', 'b.html', 'c.html'];var subpage_style = { top:'0px', bottom: '51px'}; var aniShow = {}; //创建子页面,首个选项卡页面显示,其它均隐藏;mui.plusReady(function() { var self = plus.webview.currentWebview(); for (var i = 0; i < subpages.length; i++) { var temp = {}; var sub = plus.webview.create(subpages[i], subpages[i], subpage_style); if (i > 0) { sub.hide(); }else{ temp[subpages[i]] = "true"; mui.extend(aniShow,temp); } self.append(sub); }}); //当前激活选项var activeTab = subpages[0]; //选项卡点击事件mui('.mui-bar-tab').on('tap', 'a', function(e) { var targetTab = this.getAttribute('href'); if (targetTab == activeTab) { return; } //显示目标选项卡 //若为iOS平台或非首次显示,则直接显示 if(mui.os.ios||aniShow[targetTab]){ plus.webview.show(targetTab); }else{ //否则,使用fade-in动画,且保存变量 var temp = {}; temp[targetTab] = "true"; mui.extend(aniShow,temp); plus.webview.show(targetTab,"fade-in",300); } //隐藏当前; plus.webview.hide(activeTab); //更改当前活跃的选项卡 activeTab = targetTab;}); //自定义事件,模拟点击“首页选项卡”document.addEventListener('gohome', function() { var defaultTab = document.getElementById("defaultTab"); //模拟首页点击 mui.trigger(defaultTab, 'tap'); //切换选项卡高亮 var current = document.querySelector(".mui-bar-tab>.mui-tab-item.mui-active"); if (defaultTab !== current) { current.classList.remove('mui-active'); defaultTab.classList.add('mui-active'); }});
    自定义事件监听自定义事件 - 目标页
    window.addEventListener('shijian',function(event){ //通过event.detail可获得传递过来的参数内容 .... var name = event.detail.namel console.log(name); shijian(); })
    触发自定义事件 - 本页
    //首先获得目标页面的对象var targetPage = plus.webview.getWebviewById('目标页面id'); mui.fire(targetPage,'shijian',{ //自定义事件参数 name:'write-bug'});
    页面预加载所谓的预加载技术就是在用户尚未触发页面跳转时,提前创建目标页面,这样当用户跳转时,就可以立即进行页面切换,节省创建新页面的时间,提升app使用体验。mui提供两种方式实现页面预加载。
    方式一:通过mui.init方法中的preloadPages参数进行配置
    mui.init({ // 可同时加载一个或者多个界面 preloadPages:[ //加载一个界面 { url:'a.html', id:'a', styles:{},//窗口参数 extras:{},//自定义扩展参数 subpages:[{},{}]//预加载页面的子页面 },{ // 可加载另外一个界面,不需要可直接删除 url:'b.html', id:'b', styles:{},//窗口参数 extras:{},//自定义扩展参数 subpages:[{},{}]//预加载页面的子页面 } ]});
    方式二:通过mui.preload方法预加载,一次只能预加载一个页面,若需加载多个webview,则需多次调用mui.preload()方法
    mui.plusReady(function(){ var productView = mui.preload({ url: 'list.html', id: 'list', }); console.log(productView); //获得预加载界面的对象});
    消息框警告消息框
    mui.alert('欢迎使用Hello WRITE-BUG','WRITE-BUG',function(){ alert('你刚关闭了警告框');});
    消息提示框
    var btnArray = ['是','否']; mui.confirm('WRITE-BUG技术共享平台 - 一个专注校园计算机技术交流的平台,赞?','Hello WRITE-BUG',btnArray,function(e){ if(e.index==0){ alert('点击了- 是'); //自己的逻辑 }else{ alert('点击了- 否'); }});
    输入对话框
    var btnArray = ['确定','取消']; mui.prompt('请输入你对WRITE-BUG的评语:','内容好','WRITE-BUG',btnArray,function(e){ if(e.index==0){ alert('点击了 - 确认'); var value = e.value; // value 为输入的内容 }else{ alert('点击了 - 取消'); }});
    自动消息对话框
    mui.toast('显示内容');
    日期选择框
    //js里的月份 是从0月开始的,也就是说,js中的0月是我们1月var dDate=new Date(); //默认显示的时间dDate.setFullYear(2015,5,30);var minDate=new Date(); //可选择的最小时间minDate.setFullYear(2010,0,1);var maxDate=new Date(); //课选择的最大的时间maxDate.setFullYear(2016,11,31); plus.nativeUI.pickDate( function(e) { var d=e.date; alert('您选择的日期是:'+d.getFullYear()+"-"+(d.getMonth()+1)+"-"+ d.getDate()); },function(e){ alert('您没有选择日期');},{title:"请选择日期",date:dDate,minDate:minDate,maxDate:maxDate});
    时间选择框
    var dTime=new Date();dTime.setHours(20,0); //设置默认时间plus.nativeUI.pickTime(function(e){ var d=e.date; alert("您选择的时间是:"+d.getHours()+":"+d.getMinutes()); },function (e){ alert('您没有选择时间');},{title:"请选择时间",is24Hour:true,time:dTime});
    原生模式ActionSheetvar btnArray = [{title:"分享到微信"},{title:"分享到新浪微博"},{title:"分享到搜狐微博"}]; //选择按钮 1 2 3plus.nativeUI.actionSheet( { title:"分享到", cancel:"取消", // 0 buttons:btnArray }, function(e){ var index = e.index; // alert(index); switch (index){ case 1: //写自己的逻辑 break; case 2: break; }} );
    下拉刷新为实现下拉刷新功能,大多H5框架都是通过DIV模拟下拉回弹动画,在低端android手机上,DIV动画经常出现卡顿现象(特别是图文列表的情况); 通过双webview解决这个DIV的拖动流畅度问题;拖动时,拖动的不是div,而是一个完整的webview(子webview),回弹动画使用原生动画;在iOS平台,H5的动画已经比较流畅,故依然使用H5方案。两个平台实现虽有差异,但经过封装,可使用一套代码实现下拉刷新。
    第一步: 创建子页面,因为拖动的其实是个子页面的整体
    mui.init({ subpages:[{ url:pullrefresh-subpage-url,//下拉刷新内容页面地址 id:pullrefresh-subpage-id,//内容页面标志 styles:{ top:subpage-top-position,//内容页面顶部位置,需根据实际页面布局计算,若使用标准mui导航,顶部默认为48px; .....//其它参数定义 } }] });
    第二步:内容页面需按照如下DOM结构构建
    <!--下拉刷新容器--> <div id="pullrefresh" class="mui-content mui-scroll-wrapper"> <div class="mui-scroll"> <!--数据列表--> <ul class="mui-table-view mui-table-view-chevron"> <li class="mui-table-view-cell">1</li> </ul> </div> </div>
    第三步:通过mui.init方法中pullRefresh参数配置下拉刷新各项参数
    mui.init({ pullRefresh : { container:"#pullrefresh",//下拉刷新容器标识,querySelector能定位的css选择器均可,比如:id、.class等 down : { contentdown : "下拉可以刷新",//可选,在下拉可刷新状态时,下拉刷新控件上显示的标题内容 contentover : "释放立即刷新",//可选,在释放可刷新状态时,下拉刷新控件上显示的标题内容 contentrefresh : "正在刷新...",//可选,正在刷新状态时,下拉刷新控件上显示的标题内容 callback : fn //必选,刷新函数,根据具体业务来编写,比如通过ajax从服务器获取新数据; } } });
    第四步:设置执行函数
    function fn() { //业务逻辑代码,比如通过ajax从服务器获取新数据; ...... //注意,加载完新数据后,必须执行如下代码,注意:若为ajax请求,则需将如下代码放置在处理完ajax响应数据之后 mui('#pullrefresh').pullRefresh().endPulldownToRefresh(); //这行代码会隐藏掉 正在加载... 容器}
    上拉加载第一步,第二步 和下拉刷新的一样
    第三步:通过mui.init方法中pullRefresh参数配置下拉刷新各项参数
    mui.init({ pullRefresh : { container:"#pullrefresh",//待刷新区域标识,querySelector能定位的css选择器均可,比如:id、.class等 up : { contentrefresh : "正在加载...",//可选,正在加载状态时,上拉加载控件上显示的标题内容 contentnomore:'没有更多数据了',//可选,请求完毕若没有更多数据时显示的提醒内容; callback : fn //必选,刷新函数,根据具体业务来编写,比如通过ajax从服务器获取新数据; } } });
    第四步:设置执行函数
    function fn() { //业务逻辑代码,比如通过ajax从服务器获取新数据; ...... //注意,加载完新数据后,必须执行如下代码,true表示没有更多数据了, 两个注意事项: //1、若为ajax请求,则需将如下代码放置在处理完ajax响应数据之后 // 2、注意this的作用域,若存在匿名函数,需将this复制后使用 var _this = this; _this.endPullupToRefresh(true|false); }
    上拉下拉整合第一步,第二步和下拉刷新一样
    第三步:在mui.init()内同时设置上拉加载和下拉刷新
    mui.init({ pullRefresh: { container: '#pullrefresh', down: { contentdown : "下拉可以刷新",//可选,在下拉可刷新状态时,下拉刷新控件上显示的标题内容 contentover : "释放立即刷新",//可选,在释放可刷新状态时,下拉刷新控件上显示的标题内容 contentrefresh : "正在刷新...",//可选,正在刷新状态时,下拉刷新控件上显示的标题内容 callback: downFn // 下拉执行函数 }, up: { contentrefresh: '正在加载...', callback: upFn // 上拉执行函数 } }});
    注意: 给获取元素加onclick点击事件不行,需要加addEventListener自定义事件
    手势在开发移动端的应用时,会用到很多的手势操作,比如滑动、长按等,为了方便开放者快速集成这些手势,mui内置了常用的手势事件,目前支持的手势事件见如下列表:



    分类
    参数描述




    点击



    tap
    单击屏幕


    doubletap
    双击屏幕


    长按



    longtap
    长按屏幕


    hold
    按住屏幕


    release
    离开屏幕


    滑动



    swipeleft
    向左滑动


    swiperight
    向右滑动


    swipeup
    向上滑动


    swipedown
    向下滑动


    拖动



    dragstart
    开始拖动


    drag
    拖动中


    dragend
    拖动结束



    mui.init({ gestureConfig:{ tap: true, //默认为true doubletap: true, //默认为false longtap: true, //默认为false swipe: true, //默认为true drag: true, //默认为true hold:false,//默认为false,不监听 release:false//默认为false,不监听 } });
    注意:dragstart、drag、dragend共用drag开关,swipeleft、swiperight、swipeup、swipedown共用swipe开关
    你要监听的对象.addEventListener("swipeleft",function(){ console.log("你正在向左滑动"); });
    遮罩在popover、侧滑菜单等界面,经常会用到蒙版遮罩;比如popover弹出后,除popover控件外的其它区域都会遮罩一层蒙版,用户点击蒙版不会触发蒙版下方的逻辑,而会关闭popover同时关闭蒙版;再比如侧滑菜单界面,菜单划出后,除侧滑菜单之外的其它区域都会遮罩一层蒙版,用户点击蒙版会关闭侧滑菜单同时关闭蒙版。
    遮罩蒙版常用的操作包括:创建、显示、关闭,如下代码:
    var mask = mui.createMask(callback);//callback为用户点击蒙版时自动执行的回调; mask.show();//显示遮罩mask.close();//关闭遮罩遮罩css样式: .mui-backdrop
    滑动导航选择mui提供了图片轮播、可拖动式图文表格、可拖动式选项卡、左右滑动9宫格组件,这些组件都用到了mui框架的slide插件,有较多共同点。首先,Dom内容构造基本相同,都必须有一个.mui-slider的父容器;其次,当拖动切换显示内容时,均会触发slide事件(可拖动式选项卡在点击选项卡标题时,也会触发slide事件),通过该事件的detail.slideNumber参数可以获得当前显示项的索引(第一项索引为0,第二项为1,以此类推),利用该事件,可在显示内容切换时,动态处理一些业务逻辑。
    HTML部分:
    <div class="mui-slider"> <!--选项卡标题区--> <div class="mui-slider-indicator mui-segmented-control mui-segmented-control-inverted"> <a class="mui-control-item" href="#item1">待办公文</a> <a class="mui-control-item" href="#item2">已办公文</a> <a class="mui-control-item" href="#item3">全部公文</a> </div> <div class="mui-slider-progress-bar mui-col-xs-4"></div> <div class="mui-slider-group"> <!--第一个选项卡内容区--> <div id="item1" class="mui-slider-item mui-control-content mui-active"> <ul class="mui-table-view"> <li class="mui-table-view-cell">待办公文1</li> <li class="mui-table-view-cell">待办公文2</li> <li class="mui-table-view-cell">待办公文3</li> </ul> </div> <!--第二个选项卡内容区,页面加载时为空--> <div id="item2" class="mui-slider-item mui-control-content"><ul class="mui-table-view"> <li class="mui-table-view-cell">待办公文1</li> <li class="mui-table-view-cell">待办公文2</li> <li class="mui-table-view-cell">待办公文3</li> </ul></div> <!--第三个选项卡内容区,页面加载时为空--> <div id="item3" class="mui-slider-item mui-control-content"></div> </div></div>
    JavaScript部分
    var item2Show = false,item3Show = false;//子选项卡是否显示标志document.querySelector('.mui-slider').addEventListener('slide', function(event) { if (event.detail.slideNumber === 1&&!item2Show) { //切换到第二个选项卡 //根据具体业务,动态获得第二个选项卡内容; var content = 'er'; //显示内容 document.getElementById("item2").innerHTML = content; //改变标志位,下次直接显示 item2Show = true; } else if (event.detail.slideNumber === 2&&!item3Show) { //切换到第三个选项卡 //根据具体业务,动态获得第三个选项卡内容; var content = 'san'; //显示内容 document.getElementById("item3").innerHTML = content; //改变标志位,下次直接显示 item3Show = true; }});
    图片轮播支持循环
    HTML部分:
    <div class="mui-slider"> <div class="mui-slider-group mui-slider-loop"> <!--支持循环,需要重复图片节点--> <div class="mui-slider-item mui-slider-item-duplicate"><a href="#"><img src="images/2.jpg" /></a></div> <div class="mui-slider-item"><a href="#"><img src="images/0.jpg" /></a></div> <div class="mui-slider-item"><a href="#"><img src="images/1.jpg" /></a></div> <div class="mui-slider-item"><a href="#"><img src="images/2.jpg" /></a></div> <!--支持循环,需要重复图片节点--> <div class="mui-slider-item mui-slider-item-duplicate"><a href="#"><img src="images/0.jpg" /></a></div> </div></div>
    不支持循环和循环不同的是没有再第一条和最后一条后面加入内容
    HTML部分:
    <div class="mui-slider"> <div class="mui-slider-group"> <div class="mui-slider-item"><a href="#"><img src="images/0.jpg" /></a></div> <div class="mui-slider-item"><a href="#"><img src="images/1.jpg" /></a></div> <div class="mui-slider-item"><a href="#"><img src="images/2.jpg" /></a></div> <!--<div class="mui-slider-item"><a href="#"><img src="4.jpg" /></a></div>--> </div></div>
    JavaScript部分相同:
    //获得slider插件对象var gallery = mui('.mui-slider');gallery.slider({ interval:5000//自动轮播周期,若为0则不自动播放,默认为0;});document.querySelector('.mui-slider').addEventListener('slide', function(event) { //注意slideNumber是从0开始的; alert("你正在看第"+(event.detail.slideNumber+1)+"张图片");});
    注意:如果ajax获得图片后,需要在写入图片以后,需要从新调用一下
    gallery.slider();
    自定义导航第一步:将一下代码写在header(mHeader) 和 content(mBody) 之间
    首页 科技 娱乐 财经 北京 军事 社会 汽车 视频 美女
    第二步:引入writebug_nav.css 和writebug_nav.js
    第三步:执行函数
    writebug_nav(function(index,data){ // index 为点击索引 data为点击导航的文本内容 console.log(index); console.log(data); });
    Ajax-get请求// get测试请求地址 http://test.write-bug.com/link_app/get?state=index&num=0mui.get('接口地址',{ //请求接口地址 state:'index' // 参数 键 :值 num:'0' },function(data){ // data为服务器端返回数据 //获得服务器响应 ... console.log(data); },'json' );
    Ajax-post请求// post测试请求地址 http://test.write-bug.com/link_app/postmui.post('接口地址',{ //请求接口地址 state:'index', // 参数 键 :值 num:'0' }, function(data){ //data为服务器端返回数据 //自己的逻辑 },'json');
    照相机var cmr = plus.camera.getCamera();cmr.captureImage( function ( p ) { //成功 plus.io.resolveLocalFileSystemURL( p, function ( entry ) { var img_name = entry.name;//获得图片名称 var img_path = entry.toLocalURL();//获得图片路径 }, function ( e ) { console.log( "读取拍照文件错误:"+e.message ); } ); }, function ( e ) { console.log( "失败:"+e.message );}, {filename:'_doc/camera/',index:1} ); // “_doc/camera/“ 为保存文件名
    访问相册plus.gallery.pick( function(path){ img.src = path;//获得图片路径}, function ( e ) { console.log( "取消选择图片" );}, {filter:"image"} );
    蜂鸣提示音switch ( plus.os.name ) { //判断设备类型 case "iOS": if ( plus.device.model.indexOf("iPhone") >= 0 ) { //判断是否为iPhone plus.device.beep(); console.log = "设备蜂鸣中..."; } else { console.log = "此设备不支持蜂鸣"; } break; default: plus.device.beep(); console.log = "设备蜂鸣中..."; break;}
    手机震动switch ( plus.os.name ) { //判断设备类型 case "iOS": if ( plus.device.model.indexOf("iPhone") >= 0 ) { //判断是否为iPhone plus.device.vibrate(); console.log("设备振动中..."); } else { console.log("此设备不支持振动"); } break; default: plus.device.vibrate(); console.log("设备振动中..."); break;}
    弹出菜单弹出菜单的原理主要是通过锚点进行的,如果需要多个弹出菜单,可以在a标签内设置锚点,对应相应的div的id即可
    <a href="#popover">打开弹出菜单</a> // href 定义锚点<div id="popover" class="mui-popover"> //id 对应锚点 <ul class="mui-table-view"> <li class="mui-table-view-cell"><a href="#">Item1</a></li> <li class="mui-table-view-cell"><a href="#">Item2</a></li> <li class="mui-table-view-cell"><a href="#">Item3</a></li> <li class="mui-table-view-cell"><a href="#">Item4</a></li> <li class="mui-table-view-cell"><a href="#">Item5</a></li> </ul></div>
    设备信息plus.device.model //设备型号plus.device.vendor //设备的生产厂商plus.device.imei // IMEI 设备的国际移动设备身份码plus.device.uuid // UUID 设备的唯一标识// IMSI 设备的国际移动用户识别码var str = '';for ( i=0; i<plus.device.imsi.length; i++ ) { str += plus.device.imsi[i];}plus.screen.resolutionWidth*plus.screen.scale + " x " + plus.screen.resolutionHeight*plus.screen.scale ; //屏幕分辨率plus.screen.dpiX + " x " + plus.screen.dpiY; //DPI每英寸像素数
    手机信息plus.os.name //名称plus.os.version //版本plus.os.language //语言plus.os.vendor //厂商//网络类型var types = {};types[plus.networkinfo.CONNECTION_UNKNOW] = "未知";types[plus.networkinfo.CONNECTION_NONE] = "未连接网络";types[plus.networkinfo.CONNECTION_ETHERNET] = "有线网络";types[plus.networkinfo.CONNECTION_WIFI] = "WiFi网络";types[plus.networkinfo.CONNECTION_CELL2G] = "2G蜂窝网络";types[plus.networkinfo.CONNECTION_CELL3G] = "3G蜂窝网络";types[plus.networkinfo.CONNECTION_CELL4G] = "4G蜂窝网络";var network = types[plus.networkinfo.getCurrentType()];
    发送短信<a href=“sms:10086">发送短信var msg = plus.messaging.createMessage(plus.messaging.TYPE_SMS);msg.to = ['13800138000', '13800138001'];msg.body = 'WRITE-BUG https://www.write-bug.com';plus.messaging.sendMessage( msg );
    拨打电话<a href="tel:10086">拨打电话</a>
    发送邮件<a href="mailto:write-bug@writebug.com">发送邮件到WRITE-BUG</a>
    本地存储//设置plus.storage.setItem('键','值'); -> plus.storage.setItem('name','writebug'); //查询plus.storage.getItem('键'); -> var name = plus.storage.getItem('name'); //删除plus.storage.removeItem('键'); -> plus.storage.removeItem('name'); //全部清除plus.storage.clear(); //HTML5自带 - 设置localStorage.setItem('键','值'); -> localStorage.setItem('name','writebug'); //HTML5自带 - 查询localStorage.getItem('键'); -> var name = localStorage.setItem('name');//HTML5自带 - 删除localStorage.removeItem('键'); -> localStorage.removeItem('name');
    图片上传//初始上传地址 var server="http://test.write-bug.com/upload_file.php"; var files=[]; //图片存放的数组 可以上传一个,或者多个图片 //上传图片function upload_img(p){ //又初始化了一下文件数组 为了支持我的单个上传,如果你要一次上传多个,就不要在写这一行了 //注意 files=[]; var n=p.substr(p.lastIndexOf('/')+1); files.push({name:"uploadkey",path:p}); //开始上传 start_upload(); } //开始上传function start_upload(){ if(files.length<=0){ plus.nativeUI.alert("没有添加上传文件!"); return; } //原生的转圈等待框 var wt=plus.nativeUI.showWaiting(); var task=plus.uploader.createUpload(server, {method:"POST"}, function(t,status){ //上传完成 alert(status); if(status==200){ //资源 var responseText = t.responseText; //转换成json var json = eval('(' + responseText + ')'); //上传文件的信息 var files = json.files; //上传成功以后的保存路径 var img_url = files.uploadkey.url; //ajax 写入数据库 //关闭转圈等待框 wt.close(); }else{ console.log("上传失败:"+status); //关闭原生的转圈等待框 wt.close(); } }); task.addData("client",""); task.addData("uid",getUid()); for(var i=0;i<files.length;i++){ var f=files[i]; task.addFile(f.path,{key:f.name}); } task.start(); } // 产生一个随机数function getUid(){ return Math.floor(Math.random()*100000000+10000000).toString();}
    地理位置直接获取地理位置
    plus.geolocation.getCurrentPosition( geoInfo, function ( e ) { alert( "获取位置信息失败:"+e.message );} );
    监听地理位置
    var watchId; //开关 函数外层定义 if ( watchId ) { return; } watchId = plus.geolocation.watchPosition( function ( p ) { alert( "监听位置变化信息:" ); geoInfo( p ); }, function ( e ) { alert( "监听位置变化信息失败:"+e.message ); });
    停止监听地理位置
    if ( watchId ) { alert( "停止监听位置变化信息" ); plus.geolocation.clearWatch( watchId ); watchId = null; }//获得具体地理位置//获取设备位置信息 function geoInfo(position){ var timeflag = position.timestamp;//获取到地理位置信息的时间戳;一个毫秒数; var codns = position.coords;//获取地理坐标信息; var lat = codns.latitude;//获取到当前位置的纬度; var longt = codns.longitude;//获取到当前位置的经度 var alt = codns.altitude;//获取到当前位置的海拔信息; var accu = codns.accuracy;//地理坐标信息精确度信息; var altAcc = codns.altitudeAccuracy;//获取海拔信息的精确度; var head = codns.heading;//获取设备的移动方向; var sped = codns.speed;//获取设备的移动速度; //百度地图申请地址// http://lbsyun.baidu.com/apiconsole/key// http://api.map.baidu.com/geocoder/v2/?output=json&ak=你从百度申请到的Key&location=纬度(Latitude),经度(Longitude)// http://api.map.baidu.com/geocoder/v2/?output=json&ak=BFd9490df8a776482552006c538d6b27&location=40.065639,116.419413 //详细地址 //http://api.map.baidu.com/geocoder/v2/?ak=eIxDStjzbtH0WtU50gqdXYCz&output=json&pois=1&location=40.065639,116.419413 var baidu_map = "http://api.map.baidu.com/geocoder/v2/?output=json&ak=BFd9490df8a776482552006c538d6b27&location="+lat+','+longt; mui.get(baidu_map,{ //请求的地址 }, function(data){ //服务器返回响应,根据响应结果,分析是否登录成功; ... var result = data['result'].addressComponent; // 国家 var country = result['country']; //城市 var city = result['city'];; //地址 var address = result['province'] + result['district'] + result['street']; //data 有很多信息,大家如果需要可以for in出来看下 },'json' ); }
    设置IOS状态栏mui.plusReady(function(){ if(mui.os.ios){ //UIStatusBarStyleDefault //字体深色 //UIStatusBarStyleBlackOpaque //字体浅色 plus.navigator.setStatusBarStyle('UIStatusBarStyleBlackOpaque'); plus.navigator.setStatusBarBackground("#007aff"); //背景颜色 }})
    手机通讯录mui.plusReady(function(){ //访问手机通讯录 plus.contacts.ADDRESSBOOK_PHONE //访问SIM卡通讯录 plus.contacts.ADDRESSBOOK_SIM plus.contacts.getAddressBook(plus.contacts.ADDRESSBOOK_PHONE,function(addressbook){ addressbook.find(null,function (contacts){ for(var a in contacts){ //这里是安卓手机端的获取方式 ios的不太一样,如果需要这块代码可以联系老师获得 var user = contacts[a].displayName; //联系人 var phone = contacts[a].phoneNumbers[0].value; //手机号码 } },function ( e ) {alert( "Find contact error: " +e.message );},{multi:true}); }); });
    启动页设置设置手动关闭启动页
    manifest.json -> plus -> autoclose 改为 false关闭启动页
    plus.navigator.closeSplashscreen();
    PHP后台搭建在开发工具内下载 AppServ 和 ThinkPHP,AppServ是本地服务器,ThinkPHP是后台框架
    ThinkPHP采用单入口模式 index -> 控制器 -> 方法index.php 内书写如下:define("APP_NAME",'WEB'); //站点名称define("APP_PATH",'./WEB/'); //站点路径define("APP_DEBUG",true);//开启调试模式require("./ThinkPHP/ThinkPHP.php");// 引入框架文件
    JSON转换JSON.parse()和JSON.stringify()1.parse 用于从一个字符串中解析出json 对象。例如var str='{"name":"zxd学院","age":"23"}'经 JSON.parse(str) 得到:Object: age:"23" name:"zxd学院"ps:单引号写在{}外,每个属性都必须双引号,否则会抛出异常 2.stringify用于从一个对象解析出字符串,例如 var a={a:1,b:2} 经 JSON.stringify(a)得到: '{"a":1,"b":2}'
    隐藏本页面中滚动条var self = plus.webview.currentWebview();self.setStyle({ bounce: 'none', //禁止弹动 scrollIndicator: 'none' //隐藏滚动条});
    首次启动欢迎页首先引入writebug_welcome.css 和 writebug_welcome.js 文件
    <div id="slider" class="mui-slider" > <div class="mui-slider-group"> <!-- 第一张 --> <div class="mui-slider-item"> <img src="img/shuijiao.jpg"> </div> <!-- 第二张 --> <div class="mui-slider-item"> <img src="img/muwu.jpg"> </div> <!-- 第三张 --> <div class="mui-slider-item"> <img src="img/cbd.jpg"> </div> <!-- 第四张 --> <div class="mui-slider-item"> <img src="img/yuantiao.jpg"> <button id="dy_enter">立即进入</button> </div> </div> <div class="mui-slider-indicator"> <div class="mui-indicator mui-active"></div> <div class="mui-indicator"></div> <div class="mui-indicator"></div> <div class="mui-indicator"></div> </div></div>
    writebug_welcome({ preLoadUrl:'main.html',//预加载页面url preLoadId:'main',//预加载页面id});
    数据库增删改查和接口Class UserAction extends Action { /** * 添加数据 */ public function add(){ $data['phone'] = '1380013800'; $data['name'] = 'yidong'; // M = model M('你要操作的数据表')->方法 $re = M('user')->add($data); //输出 echo $re; // 添加数据返回值 是数据的id } /** * 修改数据 */ public function mod(){ $data['phone'] = '130013000'; $id = 1; $re = M('user')->where("`id`='$id'")->save($data); echo $re; //修改数据 返回值为1是成功 0为失败 } /** * 删除数据 */ public function del(){ $id = '2'; $re = M('user')->where("`id`='$id'")->delete(); echo $re; // 删除 返回值为1也是成功 0 为失败 } /** * 查询数据 */ public function select(){ //单条带条件查询 $id = '1'; $arr1 = M('user')->where("`id`='$id'")->find(); // dump($arr1); // 多条不带条件查询 查询数据库内所有的数据 不建议使用 $arr2 = M('user')->select(); // dump($arr2); // 多条带条件查询 $phone = '1380013800'; $arr3 = M('user')->where("`phone`='$phone'")->select(); // dump($arr3); // 排序 // asc 正序 // desc 倒序 $arr4 = M('user')->where("`phone`='$phone'")->order("id desc")->select(); // dump($arr4); // 分页 limit // limit(参数1); 一个参数的情况下 拿多少条数据 // limit(参数1,参数2); 二个参数的情况下 第一个参数是从多少条开始拿,第二个参数还是拿多少条 // $arr5 = M('user')->order("id desc")->limit(2)->select(); // dump($arr5); $arr6 = M('user')->order("id desc")->limit(2,2)->select(); // dump($arr6); //返回json数据 给我们APP echo json_encode($arr6); // 接口地址 // http://127.0.0.1/www/xianshang14/index.php/User/select }}
    推送注册个推,获得APPKEY、APPID、MASTERSECRET
    推送信息必须打包安装手机后才能使用,主要是通过client_id来进行对每个用户进行推送,首先我们需要在数据库的用户表内添加一个client_id 的字段(在用户注册的时候或者在每次登录的时候存入用户的新client_id,保证推送的有效性),为存放我们用户的client_id,比如这里是个商城,你购买完商品,系统会推送一条信息给你,你只需要告诉程序,你要推送人的手机号码,标题,内容即可(如需要点击信息到达订单页面,需要用透传来实现),服务器获得手机号码以后会在数据库内查找,并获得该用户的client_id,然后实现推送。这里要根据自己的情况来写逻辑,比如WRITE-BUG课堂的分类,前端,后端,数据库等分类,如果我有一个课程上线,我可以推送给这些对某一类感兴趣的学员。当然更多的逻辑需要你自己来写,群发我们可以理解成,循环发送多个单条的(单条发送已经测试没问题,群发没测试,大家可以自己测试一下,有问题随时反馈过来)。
    由于推送信息的多样性,本次封装仅对本APP注册用户进行推送,如需要全员推送,可直接使用个推官网创建信息的方式直接推送。
    推送步骤:

    右上角下载推送包
    single.php (推送单个普通推送/可透传,点击信息可打开APP,透传可写逻辑,透传需要) (透传格式:{“path”:“course”,id:“2”}openPath.php (推送打开页面信息,点击信息可在浏览器打开你传入的URL)download.php (推送下载信息,点击信息可下载你传入URL的文件)
    简单粗暴的设置一下这3个文件内的14行APPKEY,15行APPID,16行MASTERSECRET为你在个推得到的APPKEY、APPID、MASTERSECRET

    如下我只写了一个实例,单条普通信息推送。
    PHP端代码:
    在PHP Action文件夹内建立了一个 PushAction.class.php 的文件
    Class PushAction extends Action { //单个信息推送 透传 public function single(){ $title = $_GET['title_data']; $content = $_GET['content_data']; $phone = $_GET['phone_data']; $pass = $_GET['pass_data']; if($title == '' || $content == '' || $phone == ''){ exit; } $user = M('user')->where("`phone`='$phone'")->find(); $cid = $user['client_id']; $url = 'http://' .$_SERVER['HTTP_HOST'] . . '/Push/single?title='.$title.'&content='.$content.'&cid='.$cid.'&pass='.$pass; $html = file_get_contents($url); echo $html; }}
    APP端代码 我在index文件中:
    // 监听在线消息事件plus.push.addEventListener( "receive", function( msg ) { if ( msg.aps ) { // Apple APNS message// alert( "接收到在线APNS消息:" ); } else {// alert( "接收到在线透传消息:" ); } var login_phone = localStorage.getItem('你存入的登录信息'); var content = msg.content; var json = eval('('+content+')'); var path = json.path; var id = json.id; //订单 if(path == 'order'){ if(login_phone){ dui.jump('./Home/order.html','order'); } }else if(path == 'course'){ localStorage.setItem('writebug_cid',id); dui.jump('./Course/course_detail.html','course_detail'); }else if(path == 'message'){ if(login_phone){ if(id == 'system'){ dui.jump('./Message/system_message.html','system_message'); }else{ dui.jump('./Message/chat_message.html','chat_message'); } } } }, false );
    以上PHP代码可以配合后台,给特定人群推送,逻辑需要大家实现了,因为每个APP的逻辑都不一样
    浏览器打开新页面plus.runtime.openURL( url );
    PDF浏览IOS端内可以直接打开
    安卓端方式

    调用本地第三方浏览器打开
    mui.plusReady(function(){ plus.runtime.openFile( "./file/node_js.pdf" ); });
    引入第三方js类打开

    自定义下拉刷新双webview写到父页面里面
    .mui-pull-top-pocket{ top:100px !important; position: absolute;}.mui-pull-caption { background: red;; background-size: contain; background-repeat: no-repeat; background-position: center; width: 144px; height: 31px; font-size: 0px;}/*下拉刷新圆形进度条的大小和样式*/.mui-spinner { width: 32px; height: 32px;}.mui-spinner:after { background: red;}/*下拉刷新的箭头颜色*/.mui-icon-pulldown { color: #FF058B;}
    即时聊天即时聊天采用野狗无后端模式,野狗: https://www.wilddog.com/
    引入文件
    <script src = "https://cdn.wilddog.com/js/client/current/wilddog.js" ></script>
    写入数据
    // new Wilddog message 为自己定义的一个表或者空间,用于放我们的聊天记录var wd = new Wilddog('https://write-bug.wilddogio.com/message');btn.addEventListener('tap',function(){ var content = text.value; //记录发布时间戳 var date = new Date(); var time = date.getTime(); //插入数据 //第一个参数单独的一个空间,比如两个人聊天,他们就是在单独的一个空间聊天, message 里面可以有很多个独立的空间,比如 张三和李四 是一个空间 王五和赵六又是一个空间 //第二个参数是你发布信息的时间,我们以时间作为信息的依据,通过时间的排序我们的聊天记录 //第三个参数是一个json,为我们的聊天信息,比如 昵称,头像,内容,表情,时间 wd.child('1').child(time).set({ 'name':'write-bug', 'content':content, 'time':time// ...更多 });})
    获得数据
    // 监听聊天内容变化var listen = "https://write-bug.wilddogio.com/message/1";var listen_wd = new Wilddog(listen);listen_wd.on('child_added',function(data){ list.innerHTML += '' +' '+data.val().name+' '+data.val().time+'' +' '+data.val().content+'' +''; console.log(data.val().name);})
    删除
    //1为空间名,1442293959023为某一条信息var ref = new Wilddog("https://writ-ebug.wilddogio.com/message/1/1442293959023");ref.remove()
    时间转换函数
    function getLocalTime(nS) { var mydate = new Date(nS); var today = '';// today += mydate.getFullYear() + '年'; //返回年份// today += mydate.getMonth()+1 + '月'; //返回月份,因为返回值是0开始,表示1月,所以做+1处理// today += mydate.getDate() + '日'; //返回日期 today += mydate.getHours() + ':'; if(mydate.getMinutes() < 10){ var min = '0'+mydate.getMinutes(); }else{ var min = mydate.getMinutes(); } today += min + ':'; today += mydate.getSeconds(); return today;}
    设置滚动条高度
    document.body.scrollTop = document.body.offsetHeight;
    双击安卓返回键退出//监听安卓返回键var first = null;mui.back = function() { if (!first) { first = new Date().getTime(); mui.toast('再按一次退出应用'); setTimeout(function() { first = null; }, 1000); } else { if (new Date().getTime() - first < 1000) { plus.runtime.quit(); } }}
    QQ登录
    申请各个开发平台的开发者:

    微信: https://open.weixin.qq.com/QQ: http://open.qq.com/微博: http://open.weibo.com/
    设置 manifest.json -> SDK配置
    初始化QQ登录、微信登录、微博登录

    var auths={};mui.plusReady(function(){ // 获取登录认证通道 plus.oauth.getServices(function(services){ for(var i in services){ var service=services[i]; auths[service.id]=service; } },function(e){ outLine("获取登录认证失败:"+e.message); });});
    调用认证事件
    // id 为 qq,weixin,weibo function login(id){ console.log("----- 登录认证 -----"); var auth=auths[id]; if(auth){ var w=plus.nativeUI.showWaiting(); document.addEventListener("pause",function(){ setTimeout(function(){ w&&w.close();w=null; },2000); }, false ); auth.login(function(){ w&&w.close();w=null; console.log("登录认证成功:"); console.log(JSON.stringify(auth.authResult)); userinfo(auth); },function(e){ w&&w.close();w=null; console.log("登录认证失败:"); console.log("["+e.code+"]:"+e.message); plus.nativeUI.alert("详情错误信息请参考授权登录(OAuth)规范文档:http://www.html5plus.org/#specification#/specification/OAuth.html",null,"登录失败["+e.code+"]:"+e.message); }); }else{ console.log("无效的登录认证通道!"); plus.nativeUI.alert("无效的登录认证通道!",null,"登录"); }}// 获取用户信息function userinfo(a){ console.log("----- 获取用户信息 -----"); a.getUserInfo(function(){ console.log("获取用户信息成功:"); console.log(JSON.stringify(a.userInfo)); var nickname=a.userInfo.nickname||a.userInfo.name; plus.nativeUI.alert("欢迎“"+nickname+"”登录!"); },function(e){ console.log("获取用户信息失败:"); console.log("["+e.code+"]:"+e.message); plus.nativeUI.alert("获取用户信息失败!",null,"登录"); });}// 注销登录function logoutAll(){ console.log("----- 注销登录认证 -----"); for(var i in auths){ logout(auths[i]); }}function logout(auth){ auth.logout(function(){ outLine("注销\""+auth.description+"\"成功"); },function(e){ outLine("注销\""+auth.description+"\"失败:"+e.message); });}
    5 回答 2019-03-06 21:40:56
  • 64位系统上获取SSDT表地址以及从中获取指定SSDT函数的地址

    背景SSDT 全称为 System Services Descriptor Table,即系统服务描述符表。SSDT 表的作用就是把 ring3 的 WIN32 API 函数和 ring0 的内核 API 函数联系起来。对于ring3下的一些API,最终会对应于 ntdll.dll 里一个 Ntxxx 函数,例如 CreateFile,最终调用到 ntdll.dll 里的 NtCreateFile 这个函数。NtCreateFile最终将系统服务号放入EAX,然后 CALL 系统的服务分发函数 KiSystemService,进入到内核当中。从 ring3 到 ring0,最终在 ring0 当中通过传入的 EAX 得到对应的同名系统服务的内核地址,这样就完成了一次系统服务的调用。SSDT 并不仅仅只包含一个庞大的地址索引表,它还包含着一些其它有用的信息,诸如地址索引的基地址、服务函数个数等。
    SSDT 通过修改此表的函数地址可以对常用 Windows 函数进行 HOOK,从而实现对一些核心的系统动作进行过滤、监控的目的。一些HIPS、防毒软件、系统监控、注册表监控软件往往会采用此接口来实现自己的监控模块。
    本质上,其实 SSDT 就是一个用来保存 Windows 系统服务地址的数组而已 。
    32 位系统和 64 位上,获取 SSDT 表的方式并不相同,获取 SSDT 表中的函数地址也不相同。现在,我就分别对其进行极讲解介绍,并形成文档。本文主要讲解的是 64 位系统下,编程实现获取 SSDT 表的地址,以及获取 SSDT 表函数对应的内核地址。
    实现原理获取 SSDT 表的地址在 64 位系统中,SSDT 表并没有在内核 Ntoskrnl.exe 中导出,所以,我们不能像 32 位那样直接获取导出符号 KeServiceDescriptorTable。所以,必须要使用其它方法获取。
    我们通过使用 WinDbg 在 Win7 x64、Win8.1 x64 等 64 位系统上逆向内核中的 KiSystemCall64 内核函数,逆向代码如下:
    // Win7 x64nt!KiSystemServiceRepeat:fffff800`03e8d772 4c8d15c7202300 lea r10,[nt!KeServiceDescriptorTable (fffff800`040bf840)]fffff800`03e8d779 4c8d1d00212300 lea r11,[nt!KeServiceDescriptorTableShadow (fffff800`040bf880)]… …(略)
    // Win8.1 x64nt!KiSystemServiceRepeat:fffff800`11b6c752 4c8d1567531f00 lea r10,[nt!KeServiceDescriptorTable (fffff800`11d61ac0)]fffff800`11b6c759 4c8d1da0531f00 lea r11,[nt!KeServiceDescriptorTableShadow (fffff800`11d61b00)]… …(略)
    我们从从上面的代码可以知道,KiSystemCall64 内核函数中,有调用到内核的 KeServiceDescriptorTable 以及 KeServiceDescriptorTableShadow。我们可以根据特征码 4c8d15 搜索内存方式,获取 KeServiceDescriptorTable 的偏移 offset,再计算出 KeServiceDescriptorTable 的地址,计算公式为:
    KeServiceDescriptorTable地址 = 特征码4c8d15地址 + 7 + offset
    注意,offset 可能为正,也可能为负,所以应该用有符号 4 字节数据类型来保存。
    对于 KiSystemCall64 内核函数地址的获取,虽然 Ntoskrnl.exe 也没有导出内核函数 KiSystemCall64,但是,我们可以根据下面代码获取:
    __readmsr(0xC0000082)
    直接通过读取指定的 msr 得出。msr 的中文全称是就是“特别模块寄存器”(model specific register),它控制 CPU 的工作环境和标示 CPU 的工作状态等信息(例如倍频、最大 TDP、 危险警报温度),它能够读取,也能够写入,但是无论读取还是写入,都只能在 ring 0 下进行。我们通过读取 0xC0000082 寄存器,能够得到 KiSystemCall64 的地址,然后从 KiSystemCall64 的地址开始,往下搜索特征码。
    获取 SSDT 表函数地址在 64 位下,SSDT 表的结构为:
    #pragma pack(1)typedef struct _SERVICE_DESCIPTOR_TABLE{ PULONG ServiceTableBase; // SSDT基址 PVOID ServiceCounterTableBase; // SSDT中服务被调用次数计数器 ULONGLONG NumberOfService; // SSDT服务个数 PVOID ParamTableBase; // 系统服务参数表基址}SSDTEntry, *PSSDTEntry;#pragma pack()
    和 32 位上不同的就是第 3 个成员 SSDT 服务个数 NumberOfService,由 4 字节变成了 8 字节。
    和 32 位系统不同的是,ServiceTableBase 中存放的并不是 SSDT 函数的完整地址。而是存放的是 ServiceTableBase[SSDT函数索引号]>>4 的偏移地址。那么,64 位下计算 SSDT 函数地址的完整公式为:
    ULONG ulOffset = (ServiceTableBase + SSDT函数索引号*4) >> 4;PVOID pSSDTFuncAddr = (PUCHAR)ServiceTableBase + ulOffset;// 或者ULONG ulOffset = ServiceTableBase[SSDT函数索引号] >> 4;PVOID pSSDTFuncAddr = (PUCHAR)ServiceTableBase + ulOffset;
    SSDT 函数索引号可以从 ntdll.dll 文件中获取,当 ring3 级 API 函数最终进入 ring0 级的时候,它会先将 SSDT函数索引号 mov 给 eax 寄存器。所以,我们获取 ntdll.dll 导出函数的地址,从中获取 SSDT 函数索引号。具体的实现过程分析过程,可以参考我写的《内核内存映射文件之获取SSDT函数索引号》这篇文章。
    编码实现获取SSDT函数索引号// 从 ntdll.dll 中获取 SSDT 函数索引号ULONG GetSSDTFunctionIndex(UNICODE_STRING ustrDllFileName, PCHAR pszFunctionName){ ULONG ulFunctionIndex = 0; NTSTATUS status = STATUS_SUCCESS; HANDLE hFile = NULL; HANDLE hSection = NULL; PVOID pBaseAddress = NULL; // 内存映射文件 status = DllFileMap(ustrDllFileName, &hFile, &hSection, &pBaseAddress); if (!NT_SUCCESS(status)) { KdPrint(("DllFileMap Error!\n")); return ulFunctionIndex; } // 根据导出表获取导出函数地址, 从而获取 SSDT 函数索引号 ulFunctionIndex = GetIndexFromExportTable(pBaseAddress, pszFunctionName); // 释放 ZwUnmapViewOfSection(NtCurrentProcess(), pBaseAddress); ZwClose(hSection); ZwClose(hFile); return ulFunctionIndex;}// 内存映射文件NTSTATUS DllFileMap(UNICODE_STRING ustrDllFileName, HANDLE *phFile, HANDLE *phSection, PVOID *ppBaseAddress){ NTSTATUS status = STATUS_SUCCESS; HANDLE hFile = NULL; HANDLE hSection = NULL; OBJECT_ATTRIBUTES objectAttributes = { 0 }; IO_STATUS_BLOCK iosb = { 0 }; PVOID pBaseAddress = NULL; SIZE_T viewSize = 0; // 打开 DLL 文件, 并获取文件句柄 InitializeObjectAttributes(&objectAttributes, &ustrDllFileName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL); status = ZwOpenFile(&hFile, GENERIC_READ, &objectAttributes, &iosb, FILE_SHARE_READ, FILE_SYNCHRONOUS_IO_NONALERT); if (!NT_SUCCESS(status)) { KdPrint(("ZwOpenFile Error! [error code: 0x%X]", status)); return status; } // 创建一个节对象, 以 PE 结构中的 SectionALignment 大小对齐映射文件 status = ZwCreateSection(&hSection, SECTION_MAP_READ | SECTION_MAP_WRITE, NULL, 0, PAGE_READWRITE, 0x1000000, hFile); if (!NT_SUCCESS(status)) { ZwClose(hFile); KdPrint(("ZwCreateSection Error! [error code: 0x%X]", status)); return status; } // 映射到内存 status = ZwMapViewOfSection(hSection, NtCurrentProcess(), &pBaseAddress, 0, 1024, 0, &viewSize, ViewShare, MEM_TOP_DOWN, PAGE_READWRITE); if (!NT_SUCCESS(status)) { ZwClose(hSection); ZwClose(hFile); KdPrint(("ZwMapViewOfSection Error! [error code: 0x%X]", status)); return status; } // 返回数据 *phFile = hFile; *phSection = hSection; *ppBaseAddress = pBaseAddress; return status;}// 根据导出表获取导出函数地址, 从而获取 SSDT 函数索引号ULONG GetIndexFromExportTable(PVOID pBaseAddress, PCHAR pszFunctionName){ ULONG ulFunctionIndex = 0; // Dos Header PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pBaseAddress; // NT Header PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((PUCHAR)pDosHeader + pDosHeader->e_lfanew); // Export Table PIMAGE_EXPORT_DIRECTORY pExportTable = (PIMAGE_EXPORT_DIRECTORY)((PUCHAR)pDosHeader + pNtHeaders->OptionalHeader.DataDirectory[0].VirtualAddress); // 有名称的导出函数个数 ULONG ulNumberOfNames = pExportTable->NumberOfNames; // 导出函数名称地址表 PULONG lpNameArray = (PULONG)((PUCHAR)pDosHeader + pExportTable->AddressOfNames); PCHAR lpName = NULL; // 开始遍历导出表 for (ULONG i = 0; i < ulNumberOfNames; i++) { lpName = (PCHAR)((PUCHAR)pDosHeader + lpNameArray[i]); // 判断是否查找的函数 if (0 == _strnicmp(pszFunctionName, lpName, strlen(pszFunctionName))) { // 获取导出函数地址 USHORT uHint = *(USHORT *)((PUCHAR)pDosHeader + pExportTable->AddressOfNameOrdinals + 2 * i); ULONG ulFuncAddr = *(PULONG)((PUCHAR)pDosHeader + pExportTable->AddressOfFunctions + 4 * uHint); PVOID lpFuncAddr = (PVOID)((PUCHAR)pDosHeader + ulFuncAddr); // 获取 SSDT 函数 Index#ifdef _WIN64 ulFunctionIndex = *(ULONG *)((PUCHAR)lpFuncAddr + 4);#else ulFunctionIndex = *(ULONG *)((PUCHAR)lpFuncAddr + 1);#endif break; } } return ulFunctionIndex;}
    获取SSDT表地址// 根据特征码, 从 KiSystemCall64 中获取 SSDT 地址PVOID GetSSDTAddress(){ PVOID pServiceDescriptorTable = NULL; PVOID pKiSystemCall64 = NULL; UCHAR ulCode1 = 0; UCHAR ulCode2 = 0; UCHAR ulCode3 = 0; // 注意使用有符号整型 LONG lOffset = 0; // 获取 KiSystemCall64 函数地址 pKiSystemCall64 = (PVOID)__readmsr(0xC0000082); // 搜索特征码 4C8D15 for (ULONG i = 0; i < 1024; i++) { // 获取内存数据 ulCode1 = *((PUCHAR)((PUCHAR)pKiSystemCall64 + i)); ulCode2 = *((PUCHAR)((PUCHAR)pKiSystemCall64 + i + 1)); ulCode3 = *((PUCHAR)((PUCHAR)pKiSystemCall64 + i + 2)); // 判断 if (0x4C == ulCode1 && 0x8D == ulCode2 && 0x15 == ulCode3) { // 获取偏移 lOffset = *((PLONG)((PUCHAR)pKiSystemCall64 + i + 3)); // 根据偏移计算地址 pServiceDescriptorTable = (PVOID)(((PUCHAR)pKiSystemCall64 + i) + 7 + lOffset); break; } } return pServiceDescriptorTable;}
    获取SSDT函数地址// 获取 SSDT 函数地址PVOID GetSSDTFunction(PCHAR pszFunctionName){ UNICODE_STRING ustrDllFileName; ULONG ulSSDTFunctionIndex = 0; PVOID pFunctionAddress = NULL; PSSDTEntry pServiceDescriptorTable = NULL; ULONG ulOffset = 0; RtlInitUnicodeString(&ustrDllFileName, L"\\??\\C:\\Windows\\System32\\ntdll.dll"); // 从 ntdll.dll 中获取 SSDT 函数索引号 ulSSDTFunctionIndex = GetSSDTFunctionIndex(ustrDllFileName, pszFunctionName); // 根据特征码, 从 KiSystemCall64 中获取 SSDT 地址 pServiceDescriptorTable = GetSSDTAddress(); // 根据索引号, 从SSDT表中获取对应函数偏移地址并计算出函数地址 ulOffset = pServiceDescriptorTable->ServiceTableBase[ulSSDTFunctionIndex] >> 4; pFunctionAddress = (PVOID)((PUCHAR)pServiceDescriptorTable->ServiceTableBase + ulOffset); // 显示 DbgPrint("[%s][SSDT Addr:0x%p][Index:%d][Address:0x%p]\n", pszFunctionName, pServiceDescriptorTable, ulSSDTFunctionIndex, pFunctionAddress); return pFunctionAddress;}
    程序测试在 Win7 64 位系统下,驱动程序正常执行:

    在 Win10 64 位系统下,驱动程序正常执行:

    总结64 位下的 SSDT 表不再由 Ntoskrnl.exe 导出,我们需要从 KiSystemCall64 函数中扫描内存,计算出 KeServiceDescriptorTable 的地址。而且,SSDT 结构体的数据类型和含义也有些变化。ServiceTableBase 中并不存储完整的 SSDT 函数地址,这点要注意。
    对于 SSDT 函数索引号的获取,可以从 ntdll.dll 的导出函数中获取,因为 ntdll.dll 导出函数的开头,总是将 SSDT 函数索引号 mov 到 eax 寄存器,所以,我们可以直接根据 ntdll.dll 的导出函数地址,获取 SSDT 函数索引号。
    参考参考自《Windows黑客编程技术详解》一书
    2 回答 2019-02-25 23:13:46
  • 32位系统上获取SSDT表地址以及从中获取指定SSDT函数的地址

    背景SSDT 全称为 System Services Descriptor Table,即系统服务描述符表。SSDT 表的作用就是把 ring3 的 WIN32 API 函数和 ring0 的内核 API 函数联系起来。对于ring3下的一些API,最终会对应于 ntdll.dll 里一个 Ntxxx 函数,例如 CreateFile,最终调用到 ntdll.dll 里的 NtCreateFile 这个函数。NtCreateFile最终将系统服务号放入EAX,然后 CALL 系统的服务分发函数 KiSystemService,进入到内核当中。从 ring3 到 ring0,最终在 ring0 当中通过传入的 EAX 得到对应的同名系统服务的内核地址,这样就完成了一次系统服务的调用。SSDT 并不仅仅只包含一个庞大的地址索引表,它还包含着一些其它有用的信息,诸如地址索引的基地址、服务函数个数等。
    SSDT 通过修改此表的函数地址可以对常用 Windows 函数进行 HOOK,从而实现对一些核心的系统动作进行过滤、监控的目的。一些HIPS、防毒软件、系统监控、注册表监控软件往往会采用此接口来实现自己的监控模块。
    本质上,其实 SSDT 就是一个用来保存 Windows 系统服务地址的数组而已 。
    32 位系统和 64 位上,获取 SSDT 表的方式并不相同,获取 SSDT 表中的函数地址也不相同。现在,我就分别对其进行极讲解介绍,并形成文档。本文主要讲解的是 32 位系统下,编程实现获取 SSDT 表的地址,以及获取 SSDT 表函数对应的内核地址。
    实现原理获取 SSDT 表的地址在 32 位系统中,SSDT 表是内核 Ntoskrnl.exe 导出的一张表,导出符号为 KeServiceDescriptorTable,该表含有一个指针指向SSDT中包含 Ntoskrnl.exe 实现的核心服务。所以,我们要想在 32 位系统上获取 SSDT 表地址,直接获取 Ntoskrnl.exe 导出符号 KeServiceDescriptorTable 即可。
    SSDT 表结构为:
    #pragma pack(1)typedef struct _SERVICE_DESCIPTOR_TABLE{ PULONG ServiceTableBase; // SSDT基址 PULONG ServiceCounterTableBase;// SSDT中服务被调用次数计数器 ULONG NumberOfService; // SSDT服务个数 PUCHAR ParamTableBase; // 系统服务参数表基址}SSDTEntry, *PSSDTEntry;#pragma pack()
    所以,从 Ntoskrnl.exe 获取导出符号 KeServiceDescriptorTable 的代码如下:
    extern SSDTEntry __declspec(dllimport) KeServiceDescriptorTable;
    获取 SSDT 表函数地址在 32 位系统中,SSDT 包含了所有内核导出函数的地址。每个地址长度为 4 字节。所以要获得 SSDT 中某个函数的地址,如下代码所示:
    KeServiceDescriptorTable.ServiceTableBase + SSDT函数索引号*4 // 或者KeServiceDescriptorTable.ServiceTableBase[SSDT函数索引号]
    SSDT 函数索引号可以从 ntdll.dll 文件中获取,当 ring3 级 API 函数最终进入 ring0 级的时候,它会先将 SSDT函数索引号 mov 给 eax 寄存器。所以,我们获取 ntdll.dll 导出函数的地址,从中获取 SSDT 函数索引号。具体的实现过程分析过程,可以参考我写的《内核内存映射文件之获取SSDT函数索引号》这篇文章。
    编码实现获取SSDT函数索引号// 从 ntdll.dll 中获取 SSDT 函数索引号ULONG GetSSDTFunctionIndex(UNICODE_STRING ustrDllFileName, PCHAR pszFunctionName){ ULONG ulFunctionIndex = 0; NTSTATUS status = STATUS_SUCCESS; HANDLE hFile = NULL; HANDLE hSection = NULL; PVOID pBaseAddress = NULL; // 内存映射文件 status = DllFileMap(ustrDllFileName, &hFile, &hSection, &pBaseAddress); if (!NT_SUCCESS(status)) { KdPrint(("DllFileMap Error!\n")); return ulFunctionIndex; } // 根据导出表获取导出函数地址, 从而获取 SSDT 函数索引号 ulFunctionIndex = GetIndexFromExportTable(pBaseAddress, pszFunctionName); // 释放 ZwUnmapViewOfSection(NtCurrentProcess(), pBaseAddress); ZwClose(hSection); ZwClose(hFile); return ulFunctionIndex;}// 内存映射文件NTSTATUS DllFileMap(UNICODE_STRING ustrDllFileName, HANDLE *phFile, HANDLE *phSection, PVOID *ppBaseAddress){ NTSTATUS status = STATUS_SUCCESS; HANDLE hFile = NULL; HANDLE hSection = NULL; OBJECT_ATTRIBUTES objectAttributes = { 0 }; IO_STATUS_BLOCK iosb = { 0 }; PVOID pBaseAddress = NULL; SIZE_T viewSize = 0; // 打开 DLL 文件, 并获取文件句柄 InitializeObjectAttributes(&objectAttributes, &ustrDllFileName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL); status = ZwOpenFile(&hFile, GENERIC_READ, &objectAttributes, &iosb, FILE_SHARE_READ, FILE_SYNCHRONOUS_IO_NONALERT); if (!NT_SUCCESS(status)) { KdPrint(("ZwOpenFile Error! [error code: 0x%X]", status)); return status; } // 创建一个节对象, 以 PE 结构中的 SectionALignment 大小对齐映射文件 status = ZwCreateSection(&hSection, SECTION_MAP_READ | SECTION_MAP_WRITE, NULL, 0, PAGE_READWRITE, 0x1000000, hFile); if (!NT_SUCCESS(status)) { ZwClose(hFile); KdPrint(("ZwCreateSection Error! [error code: 0x%X]", status)); return status; } // 映射到内存 status = ZwMapViewOfSection(hSection, NtCurrentProcess(), &pBaseAddress, 0, 1024, 0, &viewSize, ViewShare, MEM_TOP_DOWN, PAGE_READWRITE); if (!NT_SUCCESS(status)) { ZwClose(hSection); ZwClose(hFile); KdPrint(("ZwMapViewOfSection Error! [error code: 0x%X]", status)); return status; } // 返回数据 *phFile = hFile; *phSection = hSection; *ppBaseAddress = pBaseAddress; return status;}// 根据导出表获取导出函数地址, 从而获取 SSDT 函数索引号ULONG GetIndexFromExportTable(PVOID pBaseAddress, PCHAR pszFunctionName){ ULONG ulFunctionIndex = 0; // Dos Header PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)pBaseAddress; // NT Header PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((PUCHAR)pDosHeader + pDosHeader->e_lfanew); // Export Table PIMAGE_EXPORT_DIRECTORY pExportTable = (PIMAGE_EXPORT_DIRECTORY)((PUCHAR)pDosHeader + pNtHeaders->OptionalHeader.DataDirectory[0].VirtualAddress); // 有名称的导出函数个数 ULONG ulNumberOfNames = pExportTable->NumberOfNames; // 导出函数名称地址表 PULONG lpNameArray = (PULONG)((PUCHAR)pDosHeader + pExportTable->AddressOfNames); PCHAR lpName = NULL; // 开始遍历导出表 for (ULONG i = 0; i < ulNumberOfNames; i++) { lpName = (PCHAR)((PUCHAR)pDosHeader + lpNameArray[i]); // 判断是否查找的函数 if (0 == _strnicmp(pszFunctionName, lpName, strlen(pszFunctionName))) { // 获取导出函数地址 USHORT uHint = *(USHORT *)((PUCHAR)pDosHeader + pExportTable->AddressOfNameOrdinals + 2 * i); ULONG ulFuncAddr = *(PULONG)((PUCHAR)pDosHeader + pExportTable->AddressOfFunctions + 4 * uHint); PVOID lpFuncAddr = (PVOID)((PUCHAR)pDosHeader + ulFuncAddr); // 获取 SSDT 函数 Index#ifdef _WIN64 ulFunctionIndex = *(ULONG *)((PUCHAR)lpFuncAddr + 4);#else ulFunctionIndex = *(ULONG *)((PUCHAR)lpFuncAddr + 1);#endif break; } } return ulFunctionIndex;}
    获取SSDT表地址#pragma pack(1)typedef struct _SERVICE_DESCIPTOR_TABLE{ PULONG ServiceTableBase; // SSDT基址 PULONG ServiceCounterTableBase; // SSDT中服务被调用次数计数器 ULONG NumberOfService; // SSDT服务个数 PUCHAR ParamTableBase; // 系统服务参数表基址}SSDTEntry, *PSSDTEntry;#pragma pack()// 直接获取 SSDT extern SSDTEntry __declspec(dllimport) KeServiceDescriptorTable;
    获取SSDT函数地址// 获取 SSDT 函数地址PVOID GetSSDTFunction(PCHAR pszFunctionName){ UNICODE_STRING ustrDllFileName; ULONG ulSSDTFunctionIndex = 0; PVOID pFunctionAddress = NULL; RtlInitUnicodeString(&ustrDllFileName, L"\\??\\C:\\Windows\\System32\\ntdll.dll"); // 从 ntdll.dll 中获取 SSDT 函数索引号 ulSSDTFunctionIndex = GetSSDTFunctionIndex(ustrDllFileName, pszFunctionName); // 根据索引号, 从SSDT表中获取对应函数地址 pFunctionAddress = (PVOID)KeServiceDescriptorTable.ServiceTableBase[ulSSDTFunctionIndex]; // 显示 DbgPrint("[%s][Index:%d][Address:0x%p]\n", pszFunctionName, ulSSDTFunctionIndex, pFunctionAddress); return pFunctionAddress;}
    程序测试在 Win7 32 位系统下,驱动程序正常执行:

    在 Win10 32 位系统下,驱动程序正常执行:

    总结32 位下的 SSDT 表已经由 Ntoskrnl.exe 导出,我们直接获取导出符号 KeServiceDescriptorTable 就能获取 SSDT 表。其中,要注意 SSDT 结构体是按 1 字节大小对齐的。
    对于 SSDT 函数索引号的获取,可以从 ntdll.dll 的导出函数中获取,因为 ntdll.dll 导出函数的开头,总是将 SSDT 函数索引号 mov 到 eax 寄存器,所以,我们可以直接根据 ntdll.dll 的导出函数地址,获取 SSDT 函数索引号。
    参考参考自《Windows黑客编程技术详解》一书
    1 回答 2019-02-24 20:56:20
  • 查找并使用PspTerminateThreadByPointer函数强制结束进程可以杀360进程

    背景学习计算机的同学,或多或少都会有一个黑客情节。总是会想成为一个无拘无束的“黑客”,探索计算机世界里技术的边缘,挑战一切规则与界限。其实,正如电影《东邪西毒》里欧阳峰说的:“人都会经历这个阶段,看见一座山,就想知道山后面是什么。我很想告诉ta,可能翻过去山后面,你会发觉没有什么特别,回头看会觉得这边更好”。
    本文要介绍的就是在内核下实现,强制关掉指定进程,甚至可以关闭 360、QQ 等进程。这个技术,虽不能让你成为一名“黑客”,或许可以让你感受一把“黑科技”的瘾。现在,我就把实现过程和原理整理成文档,分享给大家。该程序适用于 32 位和 64 位 Win7 到 Win10 全平台系统。
    实现过程我们知道,线程是进程中执行运算的最小单位,是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。
    也就是说,当一个进程中的所有线程都被结束的时候,这个进程也就没有了存在的意义,也随之结束了。这,便是我们本文介绍的这种强制杀进程的实现原理,即把进程中的线程都杀掉,从而让进程消亡,实现间接杀进程的效果。
    Windows 提供了一个导出的内核函数 PsTerminateSystemThread 来帮助我们结束线程,所以,类似 360、QQ 等也会对重点监测该函数,防止结束自己的线程。我们通过逆向 PsTerminateSystemThread 函数,可以发现该函数实际上调用了未导出的内核函数 PspTerminateThreadByPointer 来实现的结束线程的操作。所以,我们可以通过查找 PspTerminateThreadByPointer 函数地址,调用直接它来结束线程,就可以绕过绝大部分的进程保护,实现强制杀进程。
    PspTerminateThreadByPointer 的函数声明为:
    NTSTATUS PspTerminateThreadByPointer ( PETHREAD pEThread, NTSTATUS ntExitCode, BOOLEAN bDirectTerminate );
    但要注意,PspTerminateThreadByPointer 的函数指针的声明的调用约定:
    // 32 位typedef NTSTATUS(*PSPTERMINATETHREADBYPOINTER_X86) ( PETHREAD pEThread, NTSTATUS ntExitCode, BOOLEAN bDirectTerminate );// 64 位typedef NTSTATUS(__fastcall *PSPTERMINATETHREADBYPOINTER_X64) ( PETHREAD pEThread, NTSTATUS ntExitCode, BOOLEAN bDirectTerminate );
    其中,PsTerminateSystemThread 里会调用 PspTerminateThreadByPointer 函数。我们使用 WinDbg 逆向 Win8.1 x64 里的 PsTerminateSystemThread 函数,如下所示:
    nt!PsTerminateSystemThread:fffff800`83904518 8bd1 mov edx,ecxfffff800`8390451a 65488b0c2588010000 mov rcx,qword ptr gs:[188h]fffff800`83904523 f7417400080000 test dword ptr [rcx+74h],800hfffff800`8390452a 7408 je nt!PsTerminateSystemThread+0x1c (fffff800`83904534)fffff800`8390452c 41b001 mov r8b,1fffff800`8390452f e978d9fcff jmp nt!PspTerminateThreadByPointer (fffff800`838d1eac)fffff800`83904534 b80d0000c0 mov eax,0C000000Dhfffff800`83904539 c3 ret
    由上面代码可以知道,我们可以通过扫描 PsTerminateSystemThread 内核函数中的特征码,从而获取 PspTerminateThreadByPointer 函数的偏移,再根据偏移计算出该函数的地址。其中,不同系统中的特征码也会不同,下面是我使用 WinDbg 逆向各个系统上总结的特征码的情况:




    Win 7
    win 8.1
    win 10




    32 位
    0xE8
    0xE8
    0xE8


    64 位
    0xE8
    0xE9
    0xE9



    那么,我们强制杀进程的实现原理为:

    首先,根据特征码扫描内存,获取 PspTerminateThreadByPointer 函数地址
    然后,调用 PsLookupProcessByProcessId 函数,根据将要结束进程 ID 获取对应的进程结构对象 EPROCESS
    接着,遍历所有的线程 ID,并调用 PsLookupThreadByThreadId 函数根据线程 ID 获取对应的线程结构 ETHREAD
    然后,调用函数 PsGetThreadProcess 获取线程结构 ETHREAD 对应的进程结构 EPROCESS
    这时,我们可以通过判断该进程是不是我们指定要结束的进程,若是,则调用 PspTerminateThreadByPointer 函数结束线程;否则,继续遍历下一个线程 ID
    重复上述 3、4、5 的操作,直到线程遍历完毕

    这样,我们就可以查杀指定进程的所有线程,线程被结束之后,进程也随之结束。注意的是,当调用 PsLookupProcessByProcessId 和 PsLookupThreadByThreadId 等 LookupXXX 系列函数获取对象的时候,都需要调用 ObDereferenceObject 函数释放对象,否则在某些时候会造成蓝屏。
    编码实现强制结束指定进程// 强制结束指定进程NTSTATUS ForceKillProcess(HANDLE hProcessId){ PVOID pPspTerminateThreadByPointerAddress = NULL; PEPROCESS pEProcess = NULL; PETHREAD pEThread = NULL; PEPROCESS pThreadEProcess = NULL; NTSTATUS status = STATUS_SUCCESS; ULONG i = 0;#ifdef _WIN64 // 64 位 typedef NTSTATUS(__fastcall *PSPTERMINATETHREADBYPOINTER) (PETHREAD pEThread, NTSTATUS ntExitCode, BOOLEAN bDirectTerminate);#else // 32 位 typedef NTSTATUS(*PSPTERMINATETHREADBYPOINTER) (PETHREAD pEThread, NTSTATUS ntExitCode, BOOLEAN bDirectTerminate);#endif // 获取 PspTerminateThreadByPointer 函数地址 pPspTerminateThreadByPointerAddress = GetPspLoadImageNotifyRoutine(); if (NULL == pPspTerminateThreadByPointerAddress) { ShowError("GetPspLoadImageNotifyRoutine", 0); return FALSE; } // 获取结束进程的进程结构对象EPROCESS status = PsLookupProcessByProcessId(hProcessId, &pEProcess); if (!NT_SUCCESS(status)) { ShowError("PsLookupProcessByProcessId", status); return status; } // 遍历所有线程, 并结束所有指定进程的线程 for (i = 4; i < 0x80000; i = i + 4) { status = PsLookupThreadByThreadId((HANDLE)i, &pEThread); if (NT_SUCCESS(status)) { // 获取线程对应的进程结构对象 pThreadEProcess = PsGetThreadProcess(pEThread); // 结束指定进程的线程 if (pEProcess == pThreadEProcess) { ((PSPTERMINATETHREADBYPOINTER)pPspTerminateThreadByPointerAddress)(pEThread, 0, 1); DbgPrint("PspTerminateThreadByPointer Thread:%d\n", i); } // 凡是Lookup...,必需Dereference,否则在某些时候会造成蓝屏 ObDereferenceObject(pEThread); } } // 凡是Lookup...,必需Dereference,否则在某些时候会造成蓝屏 ObDereferenceObject(pEProcess); return status;}
    获取 PspTerminateThreadByPointer 函数地址// 获取 PspTerminateThreadByPointer 函数地址PVOID GetPspLoadImageNotifyRoutine(){ PVOID pPspTerminateThreadByPointerAddress = NULL; RTL_OSVERSIONINFOW osInfo = { 0 }; UCHAR pSpecialData[50] = { 0 }; ULONG ulSpecialDataSize = 0; // 获取系统版本信息, 判断系统版本 RtlGetVersion(&osInfo); if (6 == osInfo.dwMajorVersion) { if (1 == osInfo.dwMinorVersion) { // Win7#ifdef _WIN64 // 64 位 // E8 pSpecialData[0] = 0xE8; ulSpecialDataSize = 1;#else // 32 位 // E8 pSpecialData[0] = 0xE8; ulSpecialDataSize = 1;#endif } else if (2 == osInfo.dwMinorVersion) { // Win8#ifdef _WIN64 // 64 位#else // 32 位#endif } else if (3 == osInfo.dwMinorVersion) { // Win8.1#ifdef _WIN64 // 64 位 // E9 pSpecialData[0] = 0xE9; ulSpecialDataSize = 1;#else // 32 位 // E8 pSpecialData[0] = 0xE8; ulSpecialDataSize = 1;#endif } } else if (10 == osInfo.dwMajorVersion) { // Win10#ifdef _WIN64 // 64 位 // E9 pSpecialData[0] = 0xE9; ulSpecialDataSize = 1;#else // 32 位 // E8 pSpecialData[0] = 0xE8; ulSpecialDataSize = 1;#endif } // 根据特征码获取地址 pPspTerminateThreadByPointerAddress = SearchPspTerminateThreadByPointer(pSpecialData, ulSpecialDataSize); return pPspTerminateThreadByPointerAddress;}
    根据特征码获取 PspTerminateThreadByPointer 数组地址// 根据特征码获取 PspTerminateThreadByPointer 数组地址PVOID SearchPspTerminateThreadByPointer(PUCHAR pSpecialData, ULONG ulSpecialDataSize){ UNICODE_STRING ustrFuncName; PVOID pAddress = NULL; LONG lOffset = 0; PVOID pPsTerminateSystemThread = NULL; PVOID pPspTerminateThreadByPointer = NULL; // 先获取 PsTerminateSystemThread 函数地址 RtlInitUnicodeString(&ustrFuncName, L"PsTerminateSystemThread"); pPsTerminateSystemThread = MmGetSystemRoutineAddress(&ustrFuncName); if (NULL == pPsTerminateSystemThread) { ShowError("MmGetSystemRoutineAddress", 0); return pPspTerminateThreadByPointer; } // 然后, 查找 PspTerminateThreadByPointer 函数地址 pAddress = SearchMemory(pPsTerminateSystemThread, (PVOID)((PUCHAR)pPsTerminateSystemThread + 0xFF), pSpecialData, ulSpecialDataSize); if (NULL == pAddress) { ShowError("SearchMemory", 0); return pPspTerminateThreadByPointer; } // 先获取偏移, 再计算地址 lOffset = *(PLONG)pAddress; pPspTerminateThreadByPointer = (PVOID)((PUCHAR)pAddress + sizeof(LONG) + lOffset); return pPspTerminateThreadByPointer;}
    指定内存区域的特征码扫描// 指定内存区域的特征码扫描PVOID SearchMemory(PVOID pStartAddress, PVOID pEndAddress, PUCHAR pMemoryData, ULONG ulMemoryDataSize){ PVOID pAddress = NULL; PUCHAR i = NULL; ULONG m = 0; // 扫描内存 for (i = (PUCHAR)pStartAddress; i < (PUCHAR)pEndAddress; i++) { // 判断特征码 for (m = 0; m < ulMemoryDataSize; m++) { if (*(PUCHAR)(i + m) != pMemoryData[m]) { break; } } // 判断是否找到符合特征码的地址 if (m >= ulMemoryDataSize) { // 找到特征码位置, 获取紧接着特征码的下一地址 pAddress = (PVOID)(i + ulMemoryDataSize); break; } } return pAddress;}
    程序测试在 Win7 32 位系统下,驱动程序正常执行:

    在 Win8.1 32 位系统下,驱动程序正常执行:

    在 Win10 32 位系统下,驱动程序正常执行:

    在 Win7 64 位系统下,驱动程序正常执行:

    在 Win8.1 64 位系统下,驱动程序正常执行:

    在 Win10 64 位系统下,驱动程序正常执行:

    总结这个程序的原理不难理解,关键是如何定位 PspTerminateThreadByPointer 未导出的内核函数在 PsTerminateSystemThread 函数中的位置,要在各个版本系统上进行逆向,以确定内存特征码。
    参考参考自《Windows黑客编程技术详解》一书
    1 回答 2019-02-23 21:08:06
  • Minifilter驱动程序与用户层程序通信

    背景通常 NT 驱动程序与用户层间的通信,可以由用户层调用 CreateFile 函数打开驱动设备并获取设备句柄,然后调用 DeviceIoControl 函数实现用户层数据和内核层数据的交互。
    那么,对于 Minifilter,它是一个 WDM 驱动,它并不像 NT 驱动那样使用常用的方式通信,而是有自己一套函数专门用于数据通信交互。现在,我就把程序的实现过程和原理整理成文档,分享给大家。
    实现过程用户层程序的实现过程导入库文件我们先来介绍下用户层上的程序的实现过程。首先,我们需要包含头文件 fltUser.h 以及库文件 fltLib.lib,这些文件在 VS 中并没有,它们存在于 WDK 中。我们可以设置程序的目录包含路径以及库文件包含路径,也可以将 WDK 中这两个文件拷贝到当前目录中来。我们选择后一种方法,将下面目录下的文件拷贝到当前目录中:

    C:\Program Files (x86)\Windows Kits\8.1\Include\um\fltUser.h
    C:\Program Files (x86)\Windows Kits\8.1\Lib\winv6.3\um\x86\fltLib.lib
    C:\Program Files (x86)\Windows Kits\8.1\Lib\winv6.3\um\x64\fltLib.lib

    那么,我们在程序中声明头文件以及导入库文件的代码为:
    #include "flt\\fltUser.h"#ifdef _WIN32 #pragma comment(lib, "flt\\lib\\x86\\fltLib.lib")#else #pragma comment(lib, "flt\\lib\\x64\\fltLib.lib")#endif
    调用函数实现交互用户层上实现于 Minifilter 内核层的数据交互方法,和用户层与 NT 驱动程序的交互方法很相似,虽然不是 CreateFile 打开对象获取句柄,在调用 DeviceIoControl 交互数据。具体的实现步骤如下:

    首先,调用 FilterConnectCommunicationPort 函数打开通信端口,获取端口的句柄
    然后,调用 FilterSendMessage 函数交互数据,向内核程序传入输入、输出缓冲区
    当交互结束,通信句柄不再使用的时候,调用 CloseHandle 函数关闭句柄

    综合上面 3 个步骤来看,是不是和 NT 驱动程序的交互方式很相似呢?我们通过类比记忆就好。其中,Minifilter 是通过端口的方式来实现数据交互的。具体的实现代码如下所示:
    int _tmain(int argc, _TCHAR* argv[]){ HANDLE hPort = NULL; char szInputBuf[MAX_PATH] = "From User Test!"; char szOutputBuf[MAX_PATH] = { 0 }; DWORD dwInputLen = 1 + ::lstrlen(szInputBuf); DWORD dwOutputLen = MAX_PATH; DWORD dwRet = 0; HRESULT hRet = NULL; // 打开并连接端口, 获取端口句柄. (类似CreateFile) hRet = ::FilterConnectCommunicationPort(PORT_NAME, 0, NULL, 0, NULL, &hPort); if (IS_ERROR(hRet)) { ::MessageBox(NULL, "FilterConnectCommunicationPort", NULL, MB_OK); return 1; } // 向端口发送数据. (类似 DeviceIoControl) hRet = ::FilterSendMessage(hPort, szInputBuf, dwInputLen, szOutputBuf, dwOutputLen, &dwRet); // 类似DeviceIoControl if (IS_ERROR(hRet)) { ::MessageBox(NULL, "FilterSendMessage", NULL, MB_OK); return 2; } // 显示数据 printf("InputBuffer:0x%x\n", szInputBuf); printf("OutputBuffer:0x%x\n", szOutputBuf); system("pause"); return 0;}
    内核层程序的实现过程从上面用户层程序的实现过程来看,和通常的交互方式来看,没有什么大区别,只是调用的函数变了而已。但是,对于内核层,却有很大的改变。
    我们知道,VS2013 里面有向导可以直接创建一个 Minifilter 驱动,可以生成代码框架和 inf 文件,这简化了很多工作。但是,VS2013 开发化境并没有帮我们生成与用户层通信部分的代码,所以,需要我们手动对代码进行更改,实现与用户层的数据通信。具体的步骤如下:
    1.首先,在内核程序的顶头声明 2 个全局变量,保存通信用的服务器端口以及客户端端口;并且声明 3 个回调函数:建立连接回调函数、数据通信回调函数、断开连接回调函数。
    // 端口名称#define PORT_NAME L"\\CommPort"// 服务器端口PFLT_PORT g_ServerPort;// 客户端端口PFLT_PORT g_ClientPort;// 建立连接回调函数NTSTATUS ConnectNotifyCallback( IN PFLT_PORT ClientPort, IN PVOID ServerPortCookies, IN PVOID ConnectionContext, IN ULONG SizeOfContext, OUT PVOID *ConnectionPortCokkie);// 数据通信回调函数NTSTATUS MessageNotifyCallback( IN PVOID PortCookie, IN PVOID InputBuffer OPTIONAL, IN ULONG InputBufferLength, OUT PVOID OutputBuffer, IN ULONG OutputBufferLength, OUT PULONG ReturnOutputBufferLength);// 断开连接回调函数VOID DisconnectNotifyCallback(_In_opt_ PVOID ConnectionCookie);
    2.然后,我们来到 DriverEntry 入口点函数,进行修改:

    首先,调用 FltRegisterFilter 注册过滤器
    然后,在使用 FltCreateCommunicationPort 函数创建通信端口之前,需要调用 FltBuildDefaultSecurityDescriptor 函数创建一个默认的安全描述符。其中,FLT_PORT_ALL_ACCESS 表示程序拥有连接到端口、访问端口等所有权限。其中,Minifilter 通常在调用 FltCreateCommunicationPort 函数之前会调用 FltBuildDefaultSecurityDescriptor 函数;在调用完 FltCreateCommunicationPort 函数后,会调用 FltFreeSecurityDescriptor 函数
    接着,调用 FltCreateCommunicationPort 创建通信服务器端口,使得Minifilter 驱动程序可以接收来自用户层程序的连接请求。可以通过该函数设置端口名称、建立连接回调函数、数据通信回调函数、断开连接回调函数、最大连接数等,同时可以获取服务器端口句柄
    然后,调用 FltFreeSecurityDescriptor 函数释放安全描述符
    最后,调用 FltStartFiltering 函数开始启动过滤注册的 Minifilter 驱动程序

    NTSTATUS DriverEntry ( _In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath ){ NTSTATUS status; UNREFERENCED_PARAMETER( RegistryPath ); PT_DBG_PRINT( PTDBG_TRACE_ROUTINES, ("Minifilter_Communicate_Test!DriverEntry: Entered\n") ); // // Register with FltMgr to tell it our callback routines // status = FltRegisterFilter( DriverObject, &FilterRegistration, &gFilterHandle ); FLT_ASSERT( NT_SUCCESS( status ) ); if (NT_SUCCESS( status )) { PSECURITY_DESCRIPTOR lpSD = NULL; // 创建安全描述, 注意:要创建这个安全描述,否则不能成功通信 status = FltBuildDefaultSecurityDescriptor(&lpSD, FLT_PORT_ALL_ACCESS); if (!NT_SUCCESS(status)) { KdPrint(("FltBuildDefaultSecurityDescriptor Error[0x%X]", status)); return status; } // 创建于用户层交互的端口 UNICODE_STRING ustrCommPort; OBJECT_ATTRIBUTES objectAttributes; RtlInitUnicodeString(&ustrCommPort, PORT_NAME); InitializeObjectAttributes(&objectAttributes, &ustrCommPort, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, lpSD); status = FltCreateCommunicationPort(gFilterHandle, &g_ServerPort, &objectAttributes, NULL, ConnectNotifyCallback, DisconnectNotifyCallback, MessageNotifyCallback, 1); if (!NT_SUCCESS(status)) { KdPrint(("FltCreateCommunicationPort Error[0x%X]", status)); return status; } // 释放安全描述 FltFreeSecurityDescriptor(lpSD); // // Start filtering i/o // status = FltStartFiltering( gFilterHandle ); if (!NT_SUCCESS( status )) { FltUnregisterFilter( gFilterHandle ); } } return status;}
    其中,建立连接回调函数的代码为:
    NTSTATUS ConnectNotifyCallback( IN PFLT_PORT ClientPort, IN PVOID ServerPortCookies, IN PVOID ConnectionContext, IN ULONG SizeOfContext, OUT PVOID *ConnectionPortCokkie){ PAGED_CODE(); UNREFERENCED_PARAMETER(ServerPortCookies); UNREFERENCED_PARAMETER(ConnectionContext); UNREFERENCED_PARAMETER(SizeOfContext); UNREFERENCED_PARAMETER(ConnectionPortCokkie); // 可以加以判断,禁止非法的连接,从而给予保护 g_ClientPort = ClientPort; // 保存以供以后使用 return STATUS_SUCCESS;}
    只要有连接连接到端口上,就会调用此函数。我们可以在该回调函数中获取客户端的端口句柄。这个客户端端口句柄要保存下来,这样,我们的驱动程序才可以和建立连接的用户层程序使用该客户端句柄进行数据通信。
    其中,断开连接回调函数的代码为:
    VOID DisconnectNotifyCallback(_In_opt_ PVOID ConnectionCookie){ PAGED_CODE(); UNREFERENCED_PARAMETER(ConnectionCookie); // 应该加判断,如果ConnectionCookie == 我们的值就执行这行 FltCloseClientPort(gFilterHandle, &g_ClientPort);}
    每当有连接断开的时候,就会调用该函数。我们需要在此调用 FltCloseClientPort 函数,关闭客户端端口。
    其中,数据交互回调函数的代码为:
    NTSTATUS MessageNotifyCallback( IN PVOID PortCookie, IN PVOID InputBuffer OPTIONAL, IN ULONG InputBufferLength, OUT PVOID OutputBuffer, IN ULONG OutputBufferLength, OUT PULONG ReturnOutputBufferLength){ /* 这里要注意: 1.数据地址的对齐. 2.文档建议使用:try/except处理. 3.如果是64位的驱动要考虑32位的EXE发来的请求. */ NTSTATUS status = STATUS_SUCCESS; PAGED_CODE(); UNREFERENCED_PARAMETER(PortCookie); /* 这里输入、输出的地址均是用户空间的地址!!! */ // 显示用户传输来的数据 KdPrint(("[InputBuffer][0x%X]%s\n", InputBuffer, (PCHAR)InputBuffer)); KdPrint(("[OutputBuffer][0x%X]\n", OutputBuffer)); // 返回内核数据到用户空间 CHAR szText[] = "From Kernel Data!"; RtlCopyMemory(OutputBuffer, szText, sizeof(szText)); *ReturnOutputBufferLength = sizeof(szText); return status;}
    每当有数据交互的时候,就会调用此回调函数。我们可以从输入缓冲区中获取来自用户层程序传入的数据。然后对输出缓冲区进行设置,将内核数据输出到用户层中。这个函数和 NT 驱动程序中的 IRP_MJ_DEVICE_CONTRL 消息对应的操作函数类似。
    3.当驱动卸载的时候,要在卸载函数中调用
    // 没有这一行是停止不了驱动的,查询也是永远等待中FltCloseCommunicationPort(g_ServerPort);
    否则,停止不了驱动的,查询也是永远等待中。
    程序测试在 Win7 32 位系统下,驱动程序正常执行:

    在 Win10 64 位系统下,驱动程序正常执行:

    总结Minifilter 的通讯结构不难理解,注意和 NT 驱动程序的驱动结构进行类比理解就好。
    要注意该程序的加载,并不像 NT 驱动那样,调用加载程序来加载。WDM驱动,采用 inf 文件的安装方式,但是,一定要注意:MiniFilter生成后,一定要修改 inf中的 Instance1.Altitude = “370030”,即将注释去掉即可。因为每一个 Minifilter 驱动都必须指定一个 Altitude。每一个发组都有自己的一个 Altitude 区间,Altitude 值越高,代表在设备栈里面的位置也越高,也就是越先收到应用层发过来的IRP。
    inf 文件安装驱动方式:

    选中inf文件,鼠标右键,选择“安装”
    安装完毕后,以管理员权限打开cmd,输入“net start 服务名”启动服务
    停止服务则使用命令“net stop 服务名”即可

    同时要注意,程序在判断文件路径的时候,要使用 ExAllocatePool 申请非分页内存,不要直接使用变量,因为使用 FltGetFileNameInformation 获取的路径信息是存储在分页内存中,直接在回调函数中使用会导致蓝屏情况。
    参考参考自《Windows黑客编程技术详解》一书
    2 回答 2019-02-15 17:40:50
  • 使用WinDbg双机调试SYS无源码驱动程序

    背景有很多学习逆向的小伙伴,逆向一些用户层的程序很熟练了,但是由于没有接触过内核驱动开发,所以对于驱动程序的逆向无从下手。
    对于驱动程序的调试可以分为有源码调试和无源码调试。本文主要讨论无源码驱动程序的调试,也就是逆向驱动程序的方法和步骤。本文演示的是使用 VMware 虚拟机和 WinDbg 程序实现双击调试。
    实现过程VMware虚拟机设置1.打开相应 WMware 虚拟机上的 “Edit virtaul machine settings”。

    2.“Hardware”选项中 —> 点击“Add”,添加一个串口设备 Serial Port 。如果有打印机(Printer)存在,则先移除虚拟机的 打印机 硬件,然后再添加串口设备 Serial Port,因为打印机会占用串口 COM1。

    3.“Next”,在“Serial Port” 里选中 “Output to named pipe”。

    4.“next”,然后如下设置:

    5.“Finish”之后,回到如下“Virtual Machine Settings”页面,在“I/O Mode” 里选中“Yield CPU on poll”。

    6.点击“OK”之后,WMware 虚拟机设置就完成了。接下来,我们开机,进入虚拟机系统中,并对虚拟机系统进行设置,将其设置成调试模式。
    虚拟机里的操作系统设置1.如果操作系统不是 Win10,则开机进入桌面后,在运行窗口输入 msconfig —> 引导 —> 高级选项 —> 调试 —> 确定。

    2.如果操作系统是 Win10,则
    1.在设置 —> 安全和更新 —> 针对开发人员 —> 开发人员模式;

    2.管理员身份运行CMD,输入 bcdedit /set testsigning on 开启测试模式;

    3.在运行窗口输入 msconfig —> 引导 —> 高级选项 —> 调试 —> 确定;

    3.关机重启,这样虚拟机里的操作系统就设置完成了。接下来,就开始在真机系统上对 WinDbg 程序进行设置并下断点调试了。
    使用 WinDbg 程序开始双机调试无源码驱动程序1.我们在真机上以管理员身份运行 WinDbg,点击 File —> Kernel Debug —> COM,然后在 Port中输入:\\.\pipe\com_1,其它都勾选上,点击“确定”。

    2.通常 WinDbg 就会一直显示 等待建立连接(Waiting to reconnect…) 状态,如下面所示:

    3.这时,需要我们点击 Break(Ctrl+Break) 暂停调试。这样,虚拟机就会停下来,我们就可以在 WinDbg 中输入命令。


    4.我们就可以输入命令,使用 WinDbg 程序来调试虚拟机中的操作系统内核。先来介绍些常用的 WinDbg 命令:
    lm :表示列举虚拟机加载和卸载的内核模块起始地址和结束地址。bu、bp :下断点。u、uf :反汇编指定地址处的代码。dd : 查看指定地址处的数据。dt : 查看数据类型定义。
    其中,bu 命令用来设置一个延迟的、以后再求解的断点,对未加载模块中的代码设置断点;当指定模块被加载时,才真正设置这个断点;这对动态加载模块的入口函数或初始化代码处加断点特别有用。
    在模块没有被加载的时候,bp 断点会失败(因为函数地址不存在),而 bu 断点则可以成功。新版的 WinDBG 中 bp 失败后会自动被转成 bu。
    那么,在无源码的驱动程序 .sys 的入口点函数 DriverEntry 中下断点的指令为:
    bp 驱动模块名称+驱动PE结构入口点函数偏移// 如:bp DriverEnum+0x1828
    5.我们演示调试无源码驱动程序 DriverEnum.sys,虚拟机系统环境是 Win10 64位。然后,在 WinDbg 程序中输入指令:bp DriverEnum+0x1828。其中,bp表示下断点;DriverEnum 表示驱动程序 DriverEnum.sys 的驱动模块名称;0x1828 表示驱动程序 DriverEnum.sys 的入口点偏移地址,这个偏移地址可以由 PE 查看工具查看得出,如下图:

    输入完下断点指令后,我们在输入 g,让系统继续执行,直到执行到断点处,便会停下。

    我们,在虚拟机系统里,加载并启动我们的驱动程序 DriverEnum.sys,在启动之后,真机上的 WinDbg 成功在 DriverEnum.sys 的入口点 DriverEntry 处停下。这时,我们可以使用快捷键 F10 或者 F10 步过或者步入来单步调试,还可以继续使用 bp 下多个断点。

    总结步骤并不复杂,只是啰嗦而已。大家细心点跟着上述教程,认真操作就可以成功对无源码的驱动程序的入口点函数 DriverEntry 下断点,实现调试。
    参考参考自《Windows黑客编程技术详解》一书
    1 回答 2019-02-09 22:44:12
显示 0 到 15 ,共 15 条
eject