├── requirements.txt
├── TZSP_Wireshark_Filter.png
├── service
├── readme.md
└── scratch_n_sniff
├── config.yaml
├── scapysnif.py
├── sniffer.py
├── scratchnsniff.py
├── README.md
└── localCapture.py
/requirements.txt:
--------------------------------------------------------------------------------
1 | pyshark==0.4.3
--------------------------------------------------------------------------------
/TZSP_Wireshark_Filter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nickvsnetworking/Scratch-n-Sniff/HEAD/TZSP_Wireshark_Filter.png
--------------------------------------------------------------------------------
/service/readme.md:
--------------------------------------------------------------------------------
1 | # Running as a Service
2 |
3 | * Update ``scratch_n_sniff`` to include the path you have it installed in, and the filter / destination info you need.
4 | * Copy the file to `/usr/bin/` and add the executable permission (``chmod +x /usr/bin/scratch_n_sniff``)
5 | * Check you can start the service and it runs / functions OK with ``scratch_n_sniff start``
6 |
--------------------------------------------------------------------------------
/config.yaml:
--------------------------------------------------------------------------------
1 | localCapture:
2 | # Maximum file size per capure, in kilobytes
3 | maxFilesize: 50000
4 | # Maximum amount of pcaps to store
5 | maxPcaps: 20
6 | # Whether to enable compression of the packet captures
7 | enableCompression: true
8 | # What to call the initial file. Note filename will be appended with sequence number and timestamp.
9 | outputFile: /etc/localcapture/localCapture.pcap
10 | # Which ports to capture traffic on
11 | portList:
12 | - 80
13 | - 443
--------------------------------------------------------------------------------
/service/scratch_n_sniff:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | PIDFILE=/var/run/scratch_n_sniff.pid
4 |
5 | case $1 in
6 | start)
7 | # Launch your program as a detached process
8 | cd /home/nick/scratch_n_sniff && python3 scratchnsniff.py --dstip 10.98.1.2 --packetfilter 'sctp or icmp' --interface lo
9 | # Get its PID and store it
10 | echo $! > ${PIDFILE}
11 | ;;
12 | stop)
13 | kill `cat ${PIDFILE}`
14 | # Now that it's killed, don't forget to remove the PID file
15 | rm ${PIDFILE}
16 | pkill -9 -f hss.py
17 | ;;
18 | *)
19 | echo "usage: scratch_n_sniff {start|stop}" ;;
20 | esac
21 | exit 0
--------------------------------------------------------------------------------
/scapysnif.py:
--------------------------------------------------------------------------------
1 | import pyshark
2 | import sys
3 | import socket
4 |
5 | dest_ip = '10.0.1.252'
6 | dest_port = 37008
7 | packet_filter = 'port 5060'
8 |
9 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
10 | tzsp_header= '0100000101'
11 |
12 | def TZSP_Encap(data):
13 |
14 | eth_header = data[0:14].hex()
15 |
16 | #Convert to Hex
17 | data = data.hex()
18 | #Ethernet Header
19 |
20 | print("eth_header: " + str(eth_header))
21 |
22 | #Append TZSP Header
23 | return tzsp_header + data
24 |
25 | def Tx(data):
26 | #Add TZSP Header
27 | data = TZSP_Encap(data)
28 | #Send it
29 | sock.sendto(bytes.fromhex(data), (dest_ip, dest_port))
30 | print("Sent!")
31 | return
32 |
33 | capture = pyshark.LiveCapture(interface='enp0s25', bpf_filter='', include_raw=True, use_json=True)
34 | for packet in capture.sniff_continuously():
35 | print('Just arrived:' + str(packet))
36 | Tx(packet.get_raw_packet())
37 | print("Sent!")
38 |
39 |
--------------------------------------------------------------------------------
/sniffer.py:
--------------------------------------------------------------------------------
1 |
2 | import logging
3 | logging.basicConfig(level=logging.DEBUG)
4 | import pyshark
5 | import socket
6 | import os
7 | import argparse
8 | import sys
9 |
10 | # Instantiate the parser
11 | ##parser = argparse.ArgumentParser(description='Scratch\'n\'Sniff - Remote Packet Capture Agent')
12 | ##parser.add_argument('--destination', type=str, required=True, help='IP to forward traffic to')
13 | ##parser.add_argument('--packet filter', type=str, required=False, help='TCPDump formatted Capture Filter')
14 | ##
15 | ##args = parser.parse_args()
16 |
17 | # bind_ip = str(args.bind_ip)
18 | # rtp_destination = str(args.rtp_destination)
19 | # rtp_port = int(args.rtp_port)
20 | # rtp_payload_id = int(args.rtp_payload_id)
21 |
22 | dest_ip = '10.0.1.252'
23 | dest_port = 37008
24 | packet_filter = 'port 5060'
25 |
26 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
27 | tzsp_header= '0100000101'
28 |
29 | def TZSP_Encap(data):
30 |
31 | eth_header = data[0:14].hex()
32 |
33 | #Convert to Hex
34 | data = data.hex()
35 | #Ethernet Header
36 |
37 | print("eth_header: " + str(eth_header))
38 |
39 | #Append TZSP Header
40 | return tzsp_header + data
41 |
42 | def Tx(data):
43 | #Add TZSP Header
44 | data = TZSP_Encap(data)
45 | #Send it
46 | sock.sendto(bytes.fromhex(data), (dest_ip, dest_port))
47 | print("Sent!")
48 | return
49 |
50 | capture = pyshark.LiveCapture(interface='any', bpf_filter=packet_filter, include_raw=True, use_json=True)
51 | logging.debug("Starting capture...")
52 | for packet in capture.sniff_continuously():
53 | print('Just arrived:' + str(packet))
54 | Tx(packet.get_raw_packet())
55 |
56 |
57 |
--------------------------------------------------------------------------------
/scratchnsniff.py:
--------------------------------------------------------------------------------
1 | import pyshark
2 | import socket
3 | import argparse
4 | import sys
5 |
6 | # Instantiate the parser
7 | parser = argparse.ArgumentParser(description='Scratch\'n\'Sniff - Remote Packet Capture Agent')
8 | parser.add_argument('--dstip', type=str, required=True, help='IP to forward traffic to')
9 | parser.add_argument('--dstport', type=int, required=False, help='Port to forward traffic to')
10 | parser.add_argument('--packetfilter', type=str, required=False, help='TCPDump formatted Capture Filter')
11 | parser.add_argument('--interface', type=str, required=True, help='Interface to capture on')
12 |
13 | args = parser.parse_args()
14 |
15 | dest_ip = str(args.dstip)
16 | if args.dstport == None:
17 | dest_port = 37008 #Set Default Port if none specified
18 | else:
19 | dest_port = int(args.dstport)
20 | packet_filter = str(args.packetfilter) + ' and not port ' + str(dest_port)
21 | interface = str(args.interface)
22 |
23 | print("Scratch\'n\'Sniff - Remote Packet Capture Agent")
24 |
25 | if interface == 'any':
26 | print("Warning: Using interface \"any\" is not supported. It makes pyshark confused and breaks things.")
27 | if packet_filter == 'None':
28 | packet_filter = 'not port ' + str(dest_port)
29 | print("Capturing and forwarding all traffic on interface " + str(interface))
30 | else:
31 | print("Forwarding all traffic matching filter " + str(packet_filter) + " on interface " + str(interface))
32 | print("To remote host " + str(dest_ip) + " on UDP port " + str(dest_port))
33 |
34 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
35 |
36 | def TZSP_Encap(data):
37 | data = data.hex() #Convert to Hex
38 | return '0100000101' + data #Append TZSP Header
39 |
40 | def Forward(data):
41 | data = TZSP_Encap(data) #Add TZSP Header
42 | #Send it
43 | sock.sendto(bytes.fromhex(data), (dest_ip, dest_port))
44 | return
45 |
46 | count = 1
47 | print("Starting capture...")
48 | capture = pyshark.LiveCapture(interface=interface, bpf_filter=packet_filter, include_raw=True, use_json=True)
49 | for packet in capture.sniff_continuously():
50 | Forward(packet.get_raw_packet())
51 | print("Forwarded " + str(count) + " matching packets to remote host.", end='\r')
52 | count += 1
53 |
54 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Scrach'n'Sniff - Simple Remote Packet Sniffer / Mirror
2 |
3 | ## What it Does
4 |
5 | Scratch'n'Sniff is a very simple remote packet sniffer, that aims to avoid the start tcpdump, capture required info, transfer with SFTP, view in Wireshark, grind.
6 |
7 | It captures packets matching the defined packet filters (standard [TCPDump filters](https://www.tcpdump.org/manpages/pcap-filter.7.html)), then encapsulates the data into TZSP and forwards / mirrors it to a remote host, which can then view the data live with Wireshark.
8 |
9 | ## Usage
10 |
11 | The required parameters are:
12 |
13 | The **interface** to capture on (ie wlan0, eth0, enp0s25, etc) - This is the interface we will capture the traffic from. Usage of all interfaces (aka 'any') is not currently supported.
14 |
15 | The **dstip** (Destination IP) to send the matching packets to, this is the remote machine you're running Wireshark or similar on.
16 |
17 | Optional parameter are:
18 |
19 | The **packetfilter** which is the [TCPDump Filter](https://www.tcpdump.org/manpages/pcap-filter.7.html) formatted filter to be applied to incoming traffic, that if matched, will see it forwarded. If this is not set then all traffic on the interface is captured.
20 |
21 | The **dstport** (Destination Port) to send the TZSP encapsulated traffic to on the remote host (defaults to 37008.)
22 |
23 | You can stop the capture with **Control + C** to exit.
24 |
25 | On the remote machine, start Wireshark, and filter by 'tzsp' and you'll see all the remote traffic being mirrored.
26 |
27 | 
28 |
29 | There's a good chance that you'll also see a lot of icmp errors, so suggest using the filter 'tzsp and not icmp' in Wireshark.
30 |
31 | ## Example Usage
32 |
33 | *Capture all traffic on port 5060 on interface enp0s25 and send it to 10.0.1.252*
34 |
35 | ```python3 scratchnsniff.py --dstip 10.0.1.252 --packetfilter 'port 5060' --interface enp0s25```
36 |
37 | Capture all sctp and icmp traffic on interface lo and send it to 10.98.1.2:
38 |
39 | ```python3 scratchnsniff.py --dstip 10.98.1.2 --packetfilter 'sctp or icmp' --interface lo```
40 |
41 | ## Installation
42 |
43 | Clone the repo and install the requirements:
44 |
45 | You will need to have installed:
46 | * Python3
47 | * pip
48 | * tcpdump
49 | * tshark
50 | All of these packages are in the repos for common Linux distros.
51 |
52 | ```
53 | apt-get install -y --assume-yes git python3-pip tshark
54 | git clone https://github.com/nickvsnetworking/Scratch-n-Sniff
55 | cd Scratch-n-Sniff
56 | pip3 install -r requirements.txt
57 | ```
58 |
59 | You can now call the program with ```python3 scratchnsniff.py``` and your arguments.
60 |
61 |
62 | ## Service / Daemon
63 | You can run the script as a service / Daemon by following the instructions [here](service/readme.md).
64 |
65 | ## Support / Contact
66 |
67 | You can find me at [NickVsNetworking](http://nickvsnetworking.com).
--------------------------------------------------------------------------------
/localCapture.py:
--------------------------------------------------------------------------------
1 | import logging
2 | logging.basicConfig(level=logging.DEBUG)
3 | import shutil
4 | import glob
5 | import time
6 | import gzip
7 | import os
8 | import socket
9 | import yaml
10 | from pathlib import Path
11 |
12 | # Local Capture agent for cylic packet capturing.
13 | # Parameters such as maximum number of pcap files kept, source port and rotation size are defined in config.yaml
14 |
15 | with open("config.yaml", 'r') as configFile:
16 | yamlConfig = (yaml.safe_load(configFile))
17 |
18 | maxFilesize = yamlConfig.get("localCapture", {}).get("maxFilesize", "200000") # Default 200mb max file size
19 | maxPcaps = yamlConfig.get("localCapture", {}).get("maxPcaps", "5") # Default max 5 pcaps
20 | outputFile = yamlConfig.get("localCapture", {}).get("outputFile", "/etc/localcapture/localCapture.pcap") # Default to the local directory
21 | outputDirectory = str(Path(outputFile).parent)
22 | portList = yamlConfig.get("localCapture", {}).get("portList", [])
23 | enableCompression = yamlConfig.get("localCapture", {}).get("enableCompression", True)
24 | hostname = socket.gethostname()
25 |
26 | # Port range keyword mappings
27 | PORT_RANGES = {
28 | 'rtp': (16384, 32767), # RTP dynamic port range
29 | 'ephemeral': (49152, 65535), # Ephemeral ports
30 | 'registered': (1024, 49151), # Registered ports
31 | 'sip': (5060, 5061), # SIP signaling
32 | }
33 |
34 | def expand_port_list(port_list):
35 | """Expand port list with keyword support into individual ports and ranges."""
36 | expanded = []
37 | for item in port_list:
38 | if isinstance(item, str) and item.lower() in PORT_RANGES:
39 | # It's a keyword, expand to all ports in the range
40 | start, end = PORT_RANGES[item.lower()]
41 | # Generate BPF filter for port range: (port >= start and port <= end)
42 | expanded.append(f'(portrange {start}-{end})')
43 | else:
44 | # It's a specific port number
45 | expanded.append(f'port {item}')
46 | return expanded
47 |
48 | #Include a port filter, if defined.
49 | portFilter = ""
50 | if portList:
51 | expanded_ports = expand_port_list(portList)
52 | portFilter = ' or '.join(expanded_ports)
53 | portFilter = '-f ' + '"' + portFilter + '"'
54 |
55 | def rotatingCapture():
56 | while True:
57 | # Check if the output directory contains more pcaps than maxPcaps. If it does, keep only the newest maxPcaps (eg 20).
58 | files = sorted(glob.glob(f'{outputDirectory}/*.pcap.gz'), key=os.path.getmtime)
59 | print(files)
60 | oldPcaps = files[:-maxPcaps] if len(files) > maxPcaps else []
61 | for file in oldPcaps:
62 | print(f"Removing {file}")
63 | os.remove(file)
64 |
65 | # Capture the PCAP
66 | print(f"Running: dumpcap -i any -w {outputFile} -a filesize:{maxFilesize} {portFilter}")
67 | os.system(f'dumpcap -i any -w {outputFile} -a filesize:{maxFilesize} {portFilter}')
68 | print("Done")
69 |
70 | timestamp = time.strftime("%Y%m%d-%H%M%S")
71 | # Give the PCAP a unique name by appending the timestamp
72 | finalFilename = f'{hostname}_{timestamp}.pcap'
73 | os.rename(outputFile, finalFilename)
74 |
75 | if enableCompression:
76 | # Compress the file using GZIP, then delete the uncompressed file
77 | with open(finalFilename, 'rb') as f_in, gzip.open(f'/{outputDirectory}/{finalFilename}' + '.gz', 'wb') as f_out:
78 | shutil.copyfileobj(f_in, f_out)
79 | os.remove(finalFilename)
80 |
81 | # Remove any stray .pcap files, incase they exist.
82 | try:
83 | os.system(f'rm {outputDirectory}/*.pcap')
84 | except Exception as e:
85 | pass
86 |
87 | # Set '777' Permissions on the PCAP folder contents
88 | os.system((f'chmod -R 777 {outputDirectory}'))
89 |
90 |
91 | if __name__ == '__main__':
92 | rotatingCapture()
93 |
--------------------------------------------------------------------------------