Scapy/Wifi Database: Difference between revisions
From charlesreid1
| (24 intermediate revisions by the same user not shown) | |||
| Line 3: | Line 3: | ||
This page covers the use of [[Scapy]] to monitor wifi channels and populate a database with observations. These observations are completely passive and are at the physical layer (hardware) only. No network traffic. | This page covers the use of [[Scapy]] to monitor wifi channels and populate a database with observations. These observations are completely passive and are at the physical layer (hardware) only. No network traffic. | ||
== | ==Scripts Required== | ||
Capturing wifi data into a database will require two scripts: | |||
* | * Scapy script to process wifi packets, parse information from them, send data to database | ||
* | * Database script to create/connect to database, listen for data | ||
<!-- | |||
=Full Script= | |||
==Source Code== | ==Source Code== | ||
| Line 135: | Line 133: | ||
# End interface settings | # End interface settings | ||
######################################## | ######################################## | ||
######################################## | ######################################## | ||
| Line 191: | Line 191: | ||
# End channel hop behavior | # End channel hop behavior | ||
######################################## | ######################################## | ||
######################################## | ######################################## | ||
| Line 385: | Line 389: | ||
</pre> | </pre> | ||
}} | }} | ||
--> | |||
=Database Creation Process= | |||
==Overview== | |||
When it comes to storing the collected wifi data in a database, you have a couple of options: | |||
1. Store the wifi data in memory (i.e., in a Python list); the database disappears at the end of the program. | |||
2. Store the wifi data in a file (i.e., in a CSV file); the database is now dumped to a file and can be parsed by other programs. Like airomon-ng with the output format option set. | |||
3. Store the wifi data in an SQL database | |||
4. Store the wifi data in a NoSQL database | |||
Now, let's walk through the options. | |||
Option 1 - this is how Aircrack works by default. Option 1 is out. Our end goal with this project will require that we capture data. | |||
Option 2 - this is how Aircrack works when output format option is set. Option 2 is also out - we have tried this already, and it is the last-resort option. | |||
Option 3 is out because types are a headache to deal with - we have tried this option already. In theory it should be easy. In reality, there's so much extra elbow grease required for converting Python types to SQL types, dealing with table schemas, and handling file-splitting logic, that this route became a total unwieldy mess. | |||
Option 4 is in. No types to bother with - straight from Python dict to MongoDB. | |||
Thus, the database creation script will essentially consist of setting up a MongoDB database/tables, running a test, viewing data, removing data, etc. | |||
==Preparing MongoDB== | |||
Start by installing MongoDB, then install Python bindings (pymodm library). On Mac OS X, you can install using Homebrew, then install Python bindings for MongoDB: | |||
<pre> | |||
brew install mongodb | |||
pip install pymodm | |||
</pre> | |||
On Debian Linux, use apt-get, then pip: | |||
<pre> | |||
# don't do apt-get install mongo-db | |||
# see https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/ | |||
pip install pymodm | |||
</pre> | |||
Once MongoDB is installed, you can start it up with the <code>mongod</code> command (you can specify different options on the command line), or you can run it and point it to a configuration file: | |||
<pre> | |||
$ mongod -f /usr/local/etc/mongod.conf | |||
</pre> | |||
Once that's done, MongoDB is installed, running, and ready to rock. | |||
==Database Schema== | |||
Here we'll explain the implementation of the wifi database (based largely on the information that was available/extractable from Scapy). This includes the schema, as well as the MongoDB commands. | |||
===NoSQL Verbiage=== | |||
Before we talk about putting data into MongoDB, let's clarify how MongoDB refers to different concepts in code. | |||
Nosql/Mongodb Concepts: | |||
* Document - a document is a chunk of related data that represents a single observation or a single record. Equivalent to SQL record. | |||
* Collection - a collection is an assembled group of documents that are all related somehow. Equivalent to SQL table. | |||
* JSON - human-readable format that can be parsed by MongoDB | |||
* Key/value - each Document in MongoDB has a set of key value pairs to store the data | |||
NOTE: The key idea behind NoSQL is that Collections do not impose any requirements on Documents. This makes NoSQL much more flexible than SQL. | |||
Mongodb Implementation: | |||
* Data Model - this refers to the actual nuts-and-bolts schema of how data is structured in the code. | |||
* Normalized Data Model - a data model where certain pieces of data in a database that refer to other pieces of data use cross-references, rather than copying and embedding the data directly. | |||
* Embedded Data Model - a data model where any data that is cross-referenced is copied and embedded directly. | |||
"You should consider embedding for performance reasons if you have a collection with a large number of small documents. If you can group these small documents by some logical relationship and you frequently retrieve the documents by this grouping, you might consider “rolling-up” the small documents into larger documents that contain an array of embedded documents." [https://docs.mongodb.com/manual/core/data-model-operations/] | |||
Nice guide to example data patterns. [https://docs.mongodb.com/manual/applications/data-models/] | |||
More on DB cross-references: [https://docs.mongodb.com/manual/reference/database-references/] | |||
===Data=== | |||
The AP data we're putting into the database depends on what type of packet is received. The type of packets we might receive from an AP that we would keep are: | |||
* Beacon frames | |||
The data available about an AP in a beacon frame is: | |||
* BSSID | |||
* Channel | |||
* SSID | |||
* Signal strength | |||
* Encryption type | |||
Thus, typical behavior for the Wifi Database program is as follows: Begin listening for packets. When a beacon packet is received, extract BSSID, channel, SSID, signal strength, and encryption information. Create a dictionary following the schema below; this will become the MongoDB document. Insert the MongoDB document into the collection. | |||
<pre> | |||
{ | |||
'bssid' : 'aa:bb:cc:dd:ee:ff:00:11', | |||
'channel' : 5, | |||
'ssid' : 'Nacho Wifi', | |||
'strength' : -20, | |||
'encryption' : 'WPA' | |||
} | |||
</pre> | |||
The data available for clients is: | |||
* Client MAC | |||
* Gateway/destination MAC | |||
* Signal strength | |||
* Channel | |||
* Associated SSIDs | |||
<pre> | |||
{ | |||
'bssid' : 'aa:bb:cc:dd:ee:ff:00:11', | |||
'channel' : 5, | |||
'ssid' : 'Nacho Wifi', | |||
'strength' : -20, | |||
'encryption' : 'WPA' | |||
} | |||
</pre> | |||
===Pymongo=== | |||
More info on pymongo here: [http://api.mongodb.com/python/current/tutorial.html] | |||
and notes here: [[Pymongo]] | |||
<!-- | |||
=Wifi Capture Script Breakdown= | |||
==Overview== | |||
Here's how this wifi capture script is going to break down: | |||
* Functions to deal with getting wifi card information, turning it off and on, and putting it in monitor mode | |||
* Functions to define channel hopping behavior | |||
* Functions to filter and print out information | |||
* Function that handles each packet | |||
* Functions to deal with new access points and new clients | |||
* Main function | |||
These functions will all be assembled into a script that we'll be able to run, and have Scapy monitor the packets coming in through a wifi interface and assemble a database of wifi observations directly from those packets. | |||
==Importing Libraries== | |||
<pre> | |||
import logging | |||
logging.getLogger("scapy.runtime").setLevel(logging.ERROR) | |||
from scapy.all import * | |||
conf.verb = 0 | |||
import os | |||
import sys | |||
import time | |||
from threading import Thread, Lock | |||
from subprocess import Popen, PIPE | |||
from signal import SIGINT, signal | |||
import argparse | |||
import socket | |||
import struct | |||
import fcntl | |||
</pre> | |||
==Handling Wifi Interfaces== | |||
Here, we have a collection of functions that deal with the wifi interfaces: | |||
<pre> | |||
######################################## | |||
# Begin interface settings | |||
######################################## | |||
def get_mon_iface(args): | |||
global monitor_on | |||
monitors, interfaces = iwconfig() | |||
if args.interface: | |||
monitor_on = True | |||
return args.interface | |||
if len(monitors) > 0: | |||
monitor_on = True | |||
return monitors[0] | |||
else: | |||
# Start monitor mode on a wireless interface | |||
print '['+G+'*'+W+'] Finding the most powerful interface...' | |||
interface = get_iface(interfaces) | |||
monmode = start_mon_mode(interface) | |||
return monmode | |||
def iwconfig(): | |||
monitors = [] | |||
interfaces = {} | |||
try: | |||
proc = Popen(['iwconfig'], stdout=PIPE, stderr=DN) | |||
except OSError: | |||
sys.exit('['+R+'-'+W+'] Could not execute "iwconfig"') | |||
for line in proc.communicate()[0].split('\n'): | |||
if len(line) == 0: continue # Isn't an empty string | |||
if line[0] != ' ': # Doesn't start with space | |||
wired_search = re.search('eth[0-9]|em[0-9]|p[1-9]p[1-9]', line) | |||
if not wired_search: # Isn't wired | |||
iface = line[:line.find(' ')] # is the interface | |||
if 'Mode:Monitor' in line: | |||
monitors.append(iface) | |||
elif 'IEEE 802.11' in line: | |||
if "ESSID:\"" in line: | |||
interfaces[iface] = 1 | |||
else: | |||
interfaces[iface] = 0 | |||
return monitors, interfaces | |||
def get_iface(interfaces): | |||
scanned_aps = [] | |||
if len(interfaces) < 1: | |||
sys.exit('['+R+'-'+W+'] No wireless interfaces found, bring one up and try again') | |||
if len(interfaces) == 1: | |||
for interface in interfaces: | |||
return interface | |||
# Find most powerful interface | |||
for iface in interfaces: | |||
count = 0 | |||
proc = Popen(['iwlist', iface, 'scan'], stdout=PIPE, stderr=DN) | |||
for line in proc.communicate()[0].split('\n'): | |||
if ' - Address:' in line: # first line in iwlist scan for a new AP | |||
count += 1 | |||
scanned_aps.append((count, iface)) | |||
print '['+G+'+'+W+'] Networks discovered by '+G+iface+W+': '+T+str(count)+W | |||
try: | |||
interface = max(scanned_aps)[1] | |||
return interface | |||
except Exception as e: | |||
for iface in interfaces: | |||
interface = iface | |||
print '['+R+'-'+W+'] Minor error:',e | |||
print ' Starting monitor mode on '+G+interface+W | |||
return interface | |||
def start_mon_mode(interface): | |||
print '['+G+'+'+W+'] Starting monitor mode off '+G+interface+W | |||
try: | |||
os.system('ifconfig %s down' % interface) | |||
os.system('iwconfig %s mode monitor' % interface) | |||
os.system('ifconfig %s up' % interface) | |||
return interface | |||
except Exception: | |||
sys.exit('['+R+'-'+W+'] Could not start monitor mode') | |||
def remove_mon_iface(mon_iface): | |||
os.system('ifconfig %s down' % mon_iface) | |||
os.system('iwconfig %s mode managed' % mon_iface) | |||
os.system('ifconfig %s up' % mon_iface) | |||
def mon_mac(mon_iface): | |||
''' | |||
http://stackoverflow.com/questions/159137/getting-mac-address | |||
''' | |||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) | |||
info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', mon_iface[:15])) | |||
mac = ''.join(['%02x:' % ord(char) for char in info[18:24]])[:-1] | |||
print '['+G+'*'+W+'] Monitor mode: '+G+mon_iface+W+' - '+O+mac+W | |||
return mac | |||
######################################## | |||
# End interface settings | |||
######################################## | |||
</pre> | |||
==Channel Hopping Behavior== | |||
We define a function to define how Scapy will hop through channels: | |||
<pre> | |||
######################################## | |||
# Set channel hop behavior | |||
######################################## | |||
def channel_hop(mon_iface, args): | |||
''' | |||
First time through, scan each channel for 5 seconds. | |||
Then skip through all channels quickly. | |||
''' | |||
global monchannel, first_pass | |||
channelNum = 0 | |||
maxChan = 11 if not args.world else 13 | |||
err = None | |||
while 1: | |||
if args.channel: | |||
with lock: | |||
monchannel = args.channel | |||
else: | |||
channelNum +=1 | |||
if channelNum > maxChan: | |||
channelNum = 1 | |||
with lock: | |||
first_pass = 0 | |||
with lock: | |||
monchannel = str(channelNum) | |||
try: | |||
proc = Popen(['iw', 'dev', mon_iface, 'set', 'channel', monchannel], stdout=DN, stderr=PIPE) | |||
except OSError: | |||
print '['+R+'-'+W+'] Could not execute "iw"' | |||
os.kill(os.getpid(),SIGINT) | |||
sys.exit(1) | |||
for line in proc.communicate()[1].split('\n'): | |||
if len(line) > 2: # iw dev shouldnt display output unless there's an error | |||
err = '['+R+'-'+W+'] Channel hopping failed: '+R+line+W | |||
output(err, monchannel) | |||
if args.channel: | |||
time.sleep(.05) | |||
else: | |||
# For the first channel hop thru, do not deauth | |||
if first_pass == 1: | |||
time.sleep(1) | |||
continue | |||
print "this is where deauth would go:" ,monchannel | |||
######################################## | |||
# End channel hop behavior | |||
######################################## | |||
</pre> | |||
==Output Filtering== | |||
Here we define what the script will print to the screen as it proceeds, and what packets it will filter out as irrelevant (NOTE: This will change quite a bit from its current state): | |||
<pre> | |||
######################################## | |||
# Set output filtering | |||
######################################## | |||
def output(err, monchannel): | |||
# | |||
# cmr: | |||
# print out information/records as they are added to the db | |||
# | |||
os.system('clear') | |||
if err: | |||
print err | |||
else: | |||
print '['+G+'+'+W+'] '+mon_iface+' channel: '+G+monchannel+W+'\n' | |||
if len(clients_APs) > 0: | |||
print ' Clients ch ESSID' | |||
# Print the clients list | |||
with lock: | |||
for ca in clients_APs: | |||
if len(ca) > 3: | |||
print '['+T+'*'+W+'] '+O+ca[0]+W+' - '+O+ca[1]+W+' - '+ca[2].ljust(2)+' - '+T+ca[3]+W | |||
else: | |||
print '['+T+'*'+W+'] '+O+ca[0]+W+' - '+O+ca[1]+W+' - '+ca[2] | |||
if len(APs) > 0: | |||
print '\n Access Points ch ESSID' | |||
with lock: | |||
for ap in APs: | |||
print '['+T+'*'+W+'] '+O+ap[0]+W+' - '+ap[1].ljust(2)+' - '+T+ap[2]+W | |||
print '' | |||
def noise_filter(addr1, addr2): | |||
ignore = ['ff:ff:ff:ff:ff:ff', # broadcast | |||
'00:00:00:00:00:00', # broadcast | |||
'33:33:00:', # ipv6 multicast | |||
'33:33:ff:', # spanning tree | |||
'01:80:c2:00:00:00', # multicast | |||
'01:00:5e:', # broadcast | |||
mon_MAC] | |||
for i in ignore: | |||
if i in addr1 or i in addr2: | |||
return True | |||
######################################## | |||
# End output filtering | |||
######################################## | |||
</pre> | |||
==Packet Handling== | |||
This is the most important function! Each time the wifi interface receives a packet, it will run the following function on that packet: | |||
<pre> | |||
######################################## | |||
# Set packet handling | |||
######################################## | |||
def cb(pkt): | |||
''' | |||
Look for dot11 packets that aren't to or from broadcast address, | |||
are type 1 or 2 (control, data), and append the addr1 and addr2 | |||
to the list of clients | |||
''' | |||
global clients_APs, APs | |||
# We're adding the AP and channel to the clients list at time of creation rather | |||
# than updating on the fly in order to avoid costly for loops that require a lock | |||
if pkt.haslayer(Dot11): | |||
if pkt.addr1 and pkt.addr2: | |||
pkt.addr1 = pkt.addr1.lower() | |||
pkt.addr2 = pkt.addr2.lower() | |||
# Filter out all other APs and clients if asked | |||
if args.accesspoint: | |||
if args.accesspoint not in [pkt.addr1, pkt.addr2]: | |||
return | |||
# Check if it's added to our AP list | |||
if pkt.haslayer(Dot11Beacon) or pkt.haslayer(Dot11ProbeResp): | |||
APs_add(clients_APs, APs, pkt, args.channel, args.world) | |||
# Ignore all the noisy packets like spanning tree | |||
if noise_filter(pkt.addr1, pkt.addr2): | |||
return | |||
# Management = 1, data = 2 | |||
if pkt.type in [1, 2]: | |||
clients_APs_add(clients_APs, pkt.addr1, pkt.addr2) | |||
def APs_add(clients_APs, APs, pkt, chan_arg, world_arg): | |||
ssid = pkt[Dot11Elt].info | |||
bssid = pkt[Dot11].addr3.lower() | |||
try: | |||
# Thanks to airoscapy for below | |||
ap_channel = str(ord(pkt[Dot11Elt:3].info)) | |||
chans = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11'] if not args.world else ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13'] | |||
if ap_channel not in chans: | |||
return | |||
if chan_arg: | |||
if ap_channel != chan_arg: | |||
return | |||
except Exception as e: | |||
return | |||
# ---------------------------------------------- | |||
# This is the money shot. | |||
# We have an observation of a bssid, ap_channel, and ssid, | |||
# plus we have the raw packet so we can get | |||
# a timestamp too. | |||
# Next step is modify this code and add it to a DB. | |||
if len(APs) == 0: | |||
with lock: | |||
return APs.append([bssid, ap_channel, ssid]) | |||
else: | |||
for b in APs: | |||
if bssid in b[0]: | |||
return | |||
with lock: | |||
return APs.append([bssid, ap_channel, ssid]) | |||
# ---------------------------------------------- | |||
def clients_APs_add(clients_APs, addr1, addr2): | |||
# ---------------------------------------------- | |||
# This is the money shot. | |||
# We have an observation of a client, addr1, addr2, channel, mac | |||
# plus we have the raw packet so we can get | |||
# a timestamp too. | |||
# Next step is modify this code and add it to a DB. | |||
if len(clients_APs) == 0: | |||
with lock: | |||
return clients_APs.append([addr1, addr2, monchannel]) | |||
# Append new clients/APs if they're not in the list | |||
else: | |||
for ca in clients_APs: | |||
if addr1 in ca and addr2 in ca: | |||
return | |||
with lock: | |||
return clients_APs.append([addr1, addr2, monchannel]) | |||
# ---------------------------------------------- | |||
######################################## | |||
# End packet handling | |||
######################################## | |||
</pre> | |||
==Stop Wifi Interfaces== | |||
Stop the wifi interfaces: | |||
<pre> | |||
def stop(signal, frame): | |||
if monitor_on: | |||
sys.exit('\n['+R+'!'+W+'] Closing') | |||
else: | |||
remove_mon_iface(mon_iface) | |||
os.system('service network-manager restart') | |||
sys.exit('\n['+R+'!'+W+'] Closing') | |||
</pre> | |||
==Main Function== | |||
FINALLY, we can get to the main function! | |||
<pre> | |||
if __name__ == "__main__": | |||
if os.geteuid(): | |||
sys.exit('['+R+'-'+W+'] Please run as root') | |||
clients_APs = [] | |||
APs = [] | |||
DN = open(os.devnull, 'w') | |||
lock = Lock() | |||
args = parse_args() | |||
monitor_on = None | |||
mon_iface = get_mon_iface(args) | |||
conf.iface = mon_iface | |||
mon_MAC = mon_mac(mon_iface) | |||
first_pass = 1 | |||
# Start channel hopping | |||
hop = Thread(target=channel_hop, args=(mon_iface, args)) | |||
hop.daemon = True | |||
hop.start() | |||
signal(SIGINT, stop) | |||
print "sniffing" | |||
sniff(iface=mon_iface, store=0, prn=cb) | |||
</pre> | |||
--> | |||
=Flags= | =Flags= | ||
Latest revision as of 05:11, 6 April 2017
Overview
This page covers the use of Scapy to monitor wifi channels and populate a database with observations. These observations are completely passive and are at the physical layer (hardware) only. No network traffic.
Scripts Required
Capturing wifi data into a database will require two scripts:
- Scapy script to process wifi packets, parse information from them, send data to database
- Database script to create/connect to database, listen for data
Database Creation Process
Overview
When it comes to storing the collected wifi data in a database, you have a couple of options: 1. Store the wifi data in memory (i.e., in a Python list); the database disappears at the end of the program. 2. Store the wifi data in a file (i.e., in a CSV file); the database is now dumped to a file and can be parsed by other programs. Like airomon-ng with the output format option set. 3. Store the wifi data in an SQL database 4. Store the wifi data in a NoSQL database
Now, let's walk through the options.
Option 1 - this is how Aircrack works by default. Option 1 is out. Our end goal with this project will require that we capture data.
Option 2 - this is how Aircrack works when output format option is set. Option 2 is also out - we have tried this already, and it is the last-resort option.
Option 3 is out because types are a headache to deal with - we have tried this option already. In theory it should be easy. In reality, there's so much extra elbow grease required for converting Python types to SQL types, dealing with table schemas, and handling file-splitting logic, that this route became a total unwieldy mess.
Option 4 is in. No types to bother with - straight from Python dict to MongoDB.
Thus, the database creation script will essentially consist of setting up a MongoDB database/tables, running a test, viewing data, removing data, etc.
Preparing MongoDB
Start by installing MongoDB, then install Python bindings (pymodm library). On Mac OS X, you can install using Homebrew, then install Python bindings for MongoDB:
brew install mongodb pip install pymodm
On Debian Linux, use apt-get, then pip:
# don't do apt-get install mongo-db # see https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/ pip install pymodm
Once MongoDB is installed, you can start it up with the mongod command (you can specify different options on the command line), or you can run it and point it to a configuration file:
$ mongod -f /usr/local/etc/mongod.conf
Once that's done, MongoDB is installed, running, and ready to rock.
Database Schema
Here we'll explain the implementation of the wifi database (based largely on the information that was available/extractable from Scapy). This includes the schema, as well as the MongoDB commands.
NoSQL Verbiage
Before we talk about putting data into MongoDB, let's clarify how MongoDB refers to different concepts in code.
Nosql/Mongodb Concepts:
- Document - a document is a chunk of related data that represents a single observation or a single record. Equivalent to SQL record.
- Collection - a collection is an assembled group of documents that are all related somehow. Equivalent to SQL table.
- JSON - human-readable format that can be parsed by MongoDB
- Key/value - each Document in MongoDB has a set of key value pairs to store the data
NOTE: The key idea behind NoSQL is that Collections do not impose any requirements on Documents. This makes NoSQL much more flexible than SQL.
Mongodb Implementation:
- Data Model - this refers to the actual nuts-and-bolts schema of how data is structured in the code.
- Normalized Data Model - a data model where certain pieces of data in a database that refer to other pieces of data use cross-references, rather than copying and embedding the data directly.
- Embedded Data Model - a data model where any data that is cross-referenced is copied and embedded directly.
"You should consider embedding for performance reasons if you have a collection with a large number of small documents. If you can group these small documents by some logical relationship and you frequently retrieve the documents by this grouping, you might consider “rolling-up” the small documents into larger documents that contain an array of embedded documents." [1]
Nice guide to example data patterns. [2]
More on DB cross-references: [3]
Data
The AP data we're putting into the database depends on what type of packet is received. The type of packets we might receive from an AP that we would keep are:
- Beacon frames
The data available about an AP in a beacon frame is:
- BSSID
- Channel
- SSID
- Signal strength
- Encryption type
Thus, typical behavior for the Wifi Database program is as follows: Begin listening for packets. When a beacon packet is received, extract BSSID, channel, SSID, signal strength, and encryption information. Create a dictionary following the schema below; this will become the MongoDB document. Insert the MongoDB document into the collection.
{
'bssid' : 'aa:bb:cc:dd:ee:ff:00:11',
'channel' : 5,
'ssid' : 'Nacho Wifi',
'strength' : -20,
'encryption' : 'WPA'
}
The data available for clients is:
- Client MAC
- Gateway/destination MAC
- Signal strength
- Channel
- Associated SSIDs
{
'bssid' : 'aa:bb:cc:dd:ee:ff:00:11',
'channel' : 5,
'ssid' : 'Nacho Wifi',
'strength' : -20,
'encryption' : 'WPA'
}
Pymongo
More info on pymongo here: [4]
and notes here: Pymongo
Flags
| scapy a Python library for interfacing with network devices and analyzing packets from Python.
Building Wireless Utilities: Scapy/Airodump Clone · Scapy/AP Scanner Analyzing Conversations: Scapy/Conversations Database: Scapy/Wifi Database Category:Scapy · Category:Python · Category:Networking
|
| Wireless all things wireless.
Software:
|