加载中...
返回

PyQt5初探——与scapy的美妙结合

PyQt5初探

自读书摘抄站点构建完成以来,一直没有进行什么有趣的实践,这回由于毕设的需要,首次尝试进行GUI编程;此外同样由于毕设需要,使用了scapy进行流量嗅探,因此就有了这一篇四不像的记录性质的文章。

Qt(音 cute )是一套被广泛使用的GUI组件库,而 PyQt 则是这套框架与Python语言的结合,是Python中最强大的GUI库之一。

本次选用这个框架其实经过了一定的曲折,因为Python的GUI库有太多了🤣

  • TkInter :python标准Tk GUI工具包的接口,我对这个库倒没什么成见,以前的项目使用过(虽然是队友做的),这次想换个别的库。

  • PySimpleGUI :框架主打一个简单易用,也是我最初选用的方案;接触了半个多小时之后放弃,文档属实写得像💩。

  • Kivy :这个库自己还开发了一套设计语言,界面文件和程序文件分离,但只有英文文档且框架本身对中文支持不好,接触了半个多小时后放弃。

  • PyQt :以上所有库放的都是官方链接,唯独这里放的是一篇中文教程,个人觉得这位大佬翻译得不错,文档学起来是比较舒服的,遂采用。

安装PyQt非常简单:

pip install PyQt5
pip install pyqt5-tools

还有简单易用的 Qt Designer ,可以直接通过拖拽的方式来放置控件、设计页面,牛逼格拉斯,但我准备手撸代码,因此没有尝试对它进行配置。

环境安装完成之后,可以照着 教程 实现一些功能。

经过一系列的探索,我的程序初具雏形:

界面有如下几个组件:

  • 在线分析:启动在线流量分析。目前还没有加入“分析”的功能,仅能对流量进行实时捕捉。

  • 结束在线分析:顾名思义。

  • 打开文件:打开一个 pcap 文件,留待后续使用,主要希望将离线分析的功能也整合进来。

  • 大大的文本框:用于输出实时流量捕捉的内容。

运行起来的效果如下:

总体实现的内容比较简单,因此后文仅对我开发过程中遇到的一些疑难杂症进行叙述。

不过,还是需要先对本文的另一个主角 scapy 进行一些介绍。

scapy初探

Scapy 是一套完整的网络报文操作工具,可以实现抓包、解包、构造和发送数据包等功能。

这次将 scapyPyQt5 结合,实际上只需要用到抓包的功能:

def StartSniff(self):
    self.stopSniff = False
    sniff(prn=self.SniffCallBack, count=0, stop_filter=self.isStop)

def SniffCallBack(self, pkt: scapy.packet.Packet) -> None:
    ''' Do Something '''

def isStop(self, pkt):
    return self.stopSniff

可以看到,这个功能只需要调用一个 sniff() 函数,并配置如下几个参数:

  • prn :抓包的回调函数。每抓到一个数据包就会调用一次这个函数,函数有个默认参数 packet ,类型是 scapy.packet.Packet ,支持对该数据包进行一些操作。

  • count :要捕捉的数据包个数,设置为 0 表示无限。

  • stop_filter :用于设置抓包停止的条件,当函数返回 True 时停止抓包。这个参数跟 count=0 简直是不可分割的,但网络上各教程中介绍不多,很迷惑。参数对应的是一个 函数 ,函数也默认传入 packet ,跟 prn 回调函数的用法区别不大。在这里,我将其配置为返回一个成员变量,当我希望停止抓包的时候,把这个成员变量改为 True 就行。

PyQt和Scapy的结合:QThread和pyqtSIgnal

现在又要将思路跳回PyQt上了,记得我开发的窗口有这些按钮:

当点击“在线分析“的时候,scapy需要开始抓包,但抓包的工作不能影响窗口本身的运行,这里就需要使用多线程 QThread

主窗体的代码如下:

class Analyzer(QWidget):
    def __init__(self) -> None:
        super().__init__()
        # 定义用于抓包的成员
        self.sniffer = Sniffer()
        self.initUI()

    def initUI(self) -> None:
        # 窗体 & 标题
        self.setGeometry(300, 300, 500, 400)
        self.setWindowTitle("Traffic Analyzer")
        # 居中定位
        self.alignCenter()
        # 设置提示框字体
        QToolTip.setFont(QFont('HanziPen TC', 10))
        # 关联事件
        self.sniffer.updateSignal.connect(self.SnifferUpdate)
        # 创建按钮
        ### 在线分析
        startBtn = QPushButton('在线分析', self, clicked=self.sniffer.start)
        startBtn.setToolTip('启动在线流量分析')
        startBtn.resize(startBtn.sizeHint())
        ### 结束在线分析
        endBtn = QPushButton('结束在线分析', self, clicked=self.sniffer.pause)
        endBtn.setToolTip('结束在线流量分析')
        endBtn.resize(endBtn.sizeHint())
        ### 打开文件
        fileBtn = QPushButton('打开文件', self)
        fileBtn.setToolTip('打开一个pcap文件')
        fileBtn.clicked.connect(self.openFileDialog)
        fileBtn.resize(fileBtn.sizeHint())
        ### 文本编辑
        self.textEdit = QTextEdit()
        self.textEdit.setReadOnly(True)
        # 设置布局
        grid = QGridLayout()
        self.setLayout(grid)
        grid.addWidget(startBtn, 0, 0)
        grid.addWidget(endBtn, 0, 1)
        grid.addWidget(fileBtn, 0, 2)
        grid.addWidget(self.textEdit, 2, 0, 5, 3)
        # 呈现
        self.show()
    ''' 省略若干不重要的函数 '''
    def SnifferUpdate(self):
        self.textEdit.setText(self.sniffer.text)
        self.textEdit.moveCursor(self.textEdit.textCursor().End)

上述代码的重点在于两个按钮的定义:

startBtn = QPushButton('在线分析', self, clicked=self.sniffer.start)
...
endBtn = QPushButton('结束在线分析', self, clicked=self.sniffer.pause)

self.sniffer 是一个自定义的对象,它应该基于 QThread 实现子线程,基于 scapy 实现抓包,基于 pyqtSignal 实现信号传递。

# 继承 QThread 实现子线程
class Sniffer(QThread):
    # 用于向主窗口传说信号
    updateSignal = pyqtSignal()

    def __init__(self) -> None:
        super().__init__()
        self.pktInfo = []
        self.text = ''
        self.stopSniff = True
    # 重写 run 函数
    def run(self):
        self.StartSniff()
    # 重写 pause 函数以停止抓包
    def pause(self):
        self.EndSniff()
    # 基于 scapy 实现抓包
    def StartSniff(self):
        self.stopSniff = False
        sniff(prn=self.SniffCallBack, count=0, stop_filter=self.isStop)

    def isStop(self, pkt):
        return self.stopSniff

    def EndSniff(self):
        self.stopSniff = True

    def SniffCallBack(self, pkt: scapy.packet.Packet) -> None:
        if pkt.haslayer('TCP'):
            infoStr = pkt.sprintf("TCP -> %IP.src%:%TCP.sport% -> %IP.dst%:%TCP.dport%")
            self.pktInfo.append(infoStr)
            if len(self.pktInfo) > 100:
                for i in self.pktInfo:
                    self.text += i + '\n'
                self.pktInfo.clear()
                # 向主窗口发送一个信号
                self.updateSignal.emit()

在这里,定义 SniffCallBack 的功能为捕捉TCP数据包,每捕捉一个包就产生一条记录信息,每捉满100个包 就向主窗体发送一个信号。

Analyzer 中对子进程信号进行了处理:

self.sniffer.updateSignal.connect(self.SnifferUpdate)
...
def SnifferUpdate(self):
    self.textEdit.setText(self.sniffer.text)
    self.textEdit.moveCursor(self.textEdit.textCursor().End)

收到信号时,主窗体从 Sniffer 当中取出现有的所有数据包的信息,显示到文本框上,并将文本框滚动条滑至底部。

总的代码如下:

from struct import pack
import sys
from telnetlib import NOP
from turtle import left
from PyQt5.QtWidgets import (QApplication, QGridLayout, 
    QToolTip, QPushButton, QMessageBox, QDesktopWidget, 
    QWidget, QTextEdit, QFileDialog)
from PyQt5.QtGui import QFont
from PyQt5.QtCore import Qt, pyqtSignal, QThread
from scapy.all import *


class Sniffer(QThread):
    updateSignal = pyqtSignal()

    def __init__(self) -> None:
        super().__init__()
        self.pktInfo = []
        self.text = ''
        self.stopSniff = True

    def run(self):
        self.StartSniff()

    def pause(self):
        self.EndSniff()

    def StartSniff(self):
        self.stopSniff = False
        sniff(prn=self.SniffCallBack, count=0, stop_filter=self.isStop)

    def isStop(self, pkt):
        return self.stopSniff

    def EndSniff(self):
        self.stopSniff = True

    def SniffCallBack(self, pkt: scapy.packet.Packet) -> None:
        if pkt.haslayer('TCP'):
            infoStr = pkt.sprintf("TCP -> %IP.src%:%TCP.sport% -> %IP.dst%:%TCP.dport%")
            self.pktInfo.append(infoStr)
            # print(infoStr + '\t' + str(len(self.pktInfo)))
            if len(self.pktInfo) > 100:
                for i in self.pktInfo:
                    self.text += i + '\n'
                self.pktInfo.clear()
                self.updateSignal.emit()


class Analyzer(QWidget):
    def __init__(self) -> None:
        super().__init__()
        self.sniffer = Sniffer()
        self.initUI()

    def initUI(self) -> None:
        # 窗体 & 标题
        self.setGeometry(300, 300, 500, 400)
        self.setWindowTitle("Traffic Analyzer")
        # 状态栏(父类 QMainWindow 与栅格布局冲突)
        # self.statusBar().showMessage("就绪")
        # 居中定位
        self.alignCenter()
        # 设置提示框字体
        QToolTip.setFont(QFont('HanziPen TC', 10))
        # 关联事件
        self.sniffer.updateSignal.connect(self.SnifferUpdate)
        # 创建按钮
        ### 在线分析
        startBtn = QPushButton('在线分析', self, clicked=self.sniffer.start)
        startBtn.setToolTip('启动在线流量分析')
        startBtn.resize(startBtn.sizeHint())
        ### 结束在线分析
        endBtn = QPushButton('结束在线分析', self, clicked=self.sniffer.pause)
        endBtn.setToolTip('结束在线流量分析')
        endBtn.resize(endBtn.sizeHint())
        ### 打开文件
        fileBtn = QPushButton('打开文件', self)
        fileBtn.setToolTip('打开一个pcap文件')
        fileBtn.clicked.connect(self.openFileDialog)
        fileBtn.resize(fileBtn.sizeHint())
        ### 文本编辑
        self.textEdit = QTextEdit()
        self.textEdit.setReadOnly(True)
        # 设置布局
        grid = QGridLayout()
        self.setLayout(grid)
        grid.addWidget(startBtn, 0, 0)
        grid.addWidget(endBtn, 0, 1)
        grid.addWidget(fileBtn, 0, 2)
        grid.addWidget(self.textEdit, 2, 0, 5, 3)
        # 呈现
        self.show()

    def closeEvent(self, event) -> None:
        reply = QMessageBox.question(self, '关闭程序', '确定关闭程序?', QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
        if reply == QMessageBox.Yes:
            event.accept()
        else:
            event.ignore()

    def alignCenter(self) -> None:
        qr = self.frameGeometry()
        cp = QDesktopWidget().availableGeometry().center()
        qr.moveCenter(cp)
        self.move(qr.topLeft())

    def keyPressEvent(self, e) -> None:
        if e.key() == Qt.Key_Escape:
            self.close()

    def openFileDialog(self):
        fname = QFileDialog.getOpenFileName(self, '打开文件', '~/', filter='*.py')
        if fname[0]:
            f = open(fname[0], 'r')
            with f:
                data = f.read()
                self.textEdit.setText(data)

    def SnifferUpdate(self):
        self.textEdit.setText(self.sniffer.text)
        self.textEdit.moveCursor(self.textEdit.textCursor().End)

if __name__ == '__main__':
    app = QApplication(sys.argv)
    analyzer = Analyzer()
    sys.exit(app.exec_())

后记

初探PyQt和Scapy,自觉本文写得杂乱,仅做自娱。代码也许还有一些参考价值,请读者自行斟酌罢。

10 comments
Anonymous
Markdown is supported
@mpv945
mpv945commentedover 2 years ago

添加图片,如果使用外部图床的http链接 。图片无法点击放大,你那边怎么解决的?

@SGS4ever
SGS4evercommentedover 2 years ago

@mpv945
添加图片,如果使用外部图床的http链接 。图片无法点击放大,你那边怎么解决的?

我的博客没有使用图床,所以没办法帮到你~

@Celetherin
Celetherincommentedover 2 years ago

您好,我也是使用的stack主题,我在照着您的方法添加返回顶部按钮时,遇到了按钮虽然出现、也能够点击,但无法实现实际上的返回顶部功能的问题,我没有任何的代码知识,不知道您有没有解决方法?
另外,也是想提醒一下其他需要这篇教程的朋友,最新版的stack主题,添加返回按钮的组件应该在layouts/partials/sidebar/right.html, 在layouts/_default/single.html中添加代码会导致出现两个右边栏。

@jsjcjsjc
jsjcjsjccommentedover 2 years ago

请教一下博主,如何优雅的给stack主题添加广告哈?
我只想在左或者右侧边栏底部,或者每篇文章底部添加一个小小的广告,但是默认似乎的满屏广告哈~~
感谢

@SGS4ever
SGS4evercommentedover 2 years ago
@ClimbingMouse
ClimbingMousecommentedalmost 2 years ago

你好,按照你的方法设置页面载入动画,这个动画不会停止咋办啊

@46fafa
46fafacommentedalmost 2 years ago

博主你好,请问一下主页布局修改哪里的代码如何作用于整个网页,我发现修改后的布局只存在主页和前两篇文章,其他部分还是没修改的样子

@4kohakunushi
4kohakunushicommentedover 1 year ago

你好,关于左侧栏图标高亮我这里存在一些问题想请教你。我取消了原本主页直接抓取post的内容在中间显示的版块,这个部分改成了其他东西,与此同时新增了一个抓取post信息的与links、search等目录并列的一个目录,现在的问题是这些部分虽然都能正常显示,但是对应的抓取post的那个目录无法选中以后高亮,应该修改增加什么才能让它也可以选中后高亮呢?

@SGS4ever
SGS4evercommentedover 1 year ago

首先我只能基于本文使用的Stack版本来尝试解答,因为没看过当前的Stack主题的代码~
我重新翻了下此前写的关于高亮的内容,理论上只要你的post页面的标题在menu配置中即可高亮。如果post页面是你站点的根路径,那应该可以参考我的文章里写的方法,修改下active的触发逻辑~

@4kohakunushi
你好,关于左侧栏图标高亮我这里存在一些问题想请教你。我取消了原本主页直接抓取post的内容在中间显示的版块,这个部分改成了其他东西,与此同时新增了一个抓取post信息的与links、search等目录并列的一个目录,现在的问题是这些部分虽然都能正常显示,但是对应的抓取post的那个目录无法选中以后高亮,应该修改增加什么才能让它也可以选中后高亮呢?

@sansan-cc
sansan-cccommented24 days ago

感谢博主的建站帖子,有很大的帮助。

有朋自远方来,不亦说乎?