├── .dockerignore ├── .gitignore ├── BuildInstructions.md ├── Dockerfile ├── README.md ├── alert_handler.py ├── build.sh ├── dhcpv6.py ├── emailer.py ├── llmnr.py ├── logger.py ├── main.py ├── mdns.py ├── nbns.py ├── patchnotes.txt ├── port_scan.py └── requirements.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | venv 2 | .gitignore 3 | logs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | logs 3 | .vscode 4 | __pycache__ -------------------------------------------------------------------------------- /BuildInstructions.md: -------------------------------------------------------------------------------- 1 | 1. Update patchnotes 2 | 2. Update readme 3 | 3. Update version in dockerfile 4 | 4. Merge to master 5 | 5. Tag master with version 6 | * `git tag -a {version} -m "{version}"` 7 | * `git tag -a 0.1 -m "0.1"` 8 | 6. Push tag 9 | * `git push --tags` 10 | 7. Docker login 11 | 8. Build using build script 12 | * `./build -v {version} .` 13 | * `./build -v 0.1` 14 | 9. Update description on docker hub -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | ENV VERSION="0.8" 4 | 5 | RUN apt-get update && apt-get install -y --no-install-recommends tcpdump && rm -rf /var/lib/apt/lists/* 6 | 7 | WORKDIR /usr/src/app 8 | VOLUME [ "/usr/src/app/logs" ] 9 | VOLUME [ "/usr/src/app/attacks" ] 10 | 11 | COPY requirements.txt ./ 12 | RUN pip install --no-cache-dir -r requirements.txt 13 | 14 | COPY . . 15 | 16 | ARG BROADCAST_IP='224.0.0.252' 17 | ENV BROADCAST_IP="${BROADCAST_IP}" 18 | ARG NBNS_SLEEP=30 19 | ENV NBNS_SLEEP="${NBNS_SLEEP}" 20 | ARG LLMNR_SLEEP=30 21 | ENV LLMNR_SLEEP="${LLMNR_SLEEP}" 22 | ARG MDNS_SLEEP=30 23 | ENV MDNS_SLEEP="${MDNS_SLEEP}" 24 | ARG DHCPV6_SLEEP=30 25 | ENV DHCPV6_SLEEP="${DHCPV6_SLEEP}" 26 | ARG PORTSCAN_TCP_PORTS='21, 25, 80, 110, 143, 443, 445, 465, 3389' 27 | ENV PORTSCAN_TCP_PORTS="${PORTSCAN_TCP_PORTS}" 28 | ARG CONSOLE_LOG_LEVEL="warning" 29 | ENV CONSOLE_LOG_LEVEL="${CONSOLE_LOG_LEVEL}" 30 | ARG FILE_LOG_LEVEL="warning" 31 | ENV FILE_LOG_LEVEL="${FILE_LOG_LEVEL}" 32 | ARG FILE_LOG_RETENTION=30 33 | ENV FILE_LOG_RETENTION="${FILE_LOG_RETENTION}" 34 | ARG DISABLE_NBNS_SCANNING='F' 35 | ENV DISABLE_NBNS_SCANNING="${DISABLE_NBNS_SCANNING}" 36 | ARG DISABLE_LLMNR_SCANNING='F' 37 | ENV DISABLE_LLMNR_SCANNING="${DISABLE_LLMNR_SCANNING}" 38 | ARG DISABLE_MDNS_SCANNING='F' 39 | ENV DISABLE_MDNS_SCANNING="${DISABLE_MDNS_SCANNING}" 40 | ARG DISABLE_PORTSCAN_DETECTION='F' 41 | ENV DISABLE_PORTSCAN_DETECTION="${DISABLE_PORTSCAN_DETECTION}" 42 | ARG DISABLE_DHCPV6_DETECTION='F' 43 | ENV DISABLE_DHCPV6_DETECTION="${DISABLE_DHCPV6_DETECTION}" 44 | ARG DHCPV6_WHITELIST='' 45 | ENV DHCPV6_WHITELIST="${DHCPV6_WHITELIST}" 46 | ARG ENABLE_EMAIL_ALERTS='T' 47 | ENV ENABLE_EMAIL_ALERTS="${ENABLE_EMAIL_ALERTS}" 48 | ARG ENABLE_EMAIL_STARTUP_TEST='T' 49 | ENV ENABLE_EMAIL_STARTUP_TEST="${ENABLE_EMAIL_STARTUP_TEST}" 50 | ARG ENABLE_EMAIL_SERVER_AUTHENTICATION='T' 51 | ENV ENABLE_EMAIL_SERVER_AUTHENTICATION="${ENABLE_EMAIL_SERVER_AUTHENTICATION}" 52 | ARG EMAIL_RECIPIENT='' 53 | ENV EMAIL_RECIPIENT="${EMAIL_RECIPIENT}" 54 | ARG EMAIL_SENDER='' 55 | ENV EMAIL_SENDER="${EMAIL_SENDER}" 56 | ARG EMAIL_SENDER_PASSWORD='' 57 | ENV EMAIL_SENDER_PASSWORD="${EMAIL_SENDER_PASSWORD}" 58 | ARG EMAIL_SERVER_ADDRESS='smtp.gmail.com' 59 | ENV EMAIL_SERVER_ADDRESS="${EMAIL_SERVER_ADDRESS}" 60 | ARG EMAIL_SERVER_PORT=587 61 | ENV EMAIL_SERVER_PORT="${EMAIL_SERVER_PORT}" 62 | ARG EMAIL_SERVER_STARTTLS='T' 63 | ENV EMAIL_SERVER_STARTTLS="${EMAIL_SERVER_STARTTLS}" 64 | ARG ATTACK_TIMEOUT_DURATION=600 65 | ENV ATTACK_TIMEOUT_DURATION="${ATTACK_TIMEOUT_DURATION}" 66 | ARG SYSLOG_ENABLED='F' 67 | ENV SYSLOG_ENABLED="${SYSLOG_ENABLED}" 68 | ARG SYSLOG_ADDRESS='' 69 | ENV SYSLOG_ADDRESS="${SYSLOG_ADDRESS}" 70 | ARG SYSLOG_PORT=514 71 | ENV SYSLOG_PORT="${SYSLOG_PORT}" 72 | ARG SYSLOG_TCP='F' 73 | ENV SYSLOG_TCP="${SYSLOG_TCP}" 74 | ARG SYSLOG_LOG_LEVEL='warning' 75 | ENV SYSLOG_LOG_LEVEL="${SYSLOG_LOG_LEVEL}" 76 | 77 | CMD [ "python", "./main.py" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CanaryPi 2 | Startup project to create a simple to deploy honey pot style detection tool for alerting on common network attacks. This is currently and early alpha build used for public testing and feedback while it's further developed. 3 | 4 | # Requirements 5 | This container requires the --net=host option when starting. At the time of writing this that option is only supported in linux so this container will not work on Windows or Mac Os hosts. This is because the scripts need to send broadcast packets to the host network. The docker container is cross compiled using buildx so canarypi should be able to run on x86 or arm machines. 6 | 7 | # Plan 8 | I'm hoping to take some pre-existing tools and techniques for detecting common network attacks, like responder and port scanning, and package them in an easy to deploy tool. I'm hoping this will help small IT teams that can't afford fancy security tools detect attacks on their network. 9 | 10 | # Current Features 11 | Currently the following attacks can be detected on a network 12 | 1. NBNS spoofing. Typically from [Responder](https://github.com/lgandx/Responder) 13 | 2. LLMNR spoofing. Typically from [Responder](https://github.com/lgandx/Responder) 14 | 3. mDNS spoofing. Typically from [Responder](https://github.com/lgandx/Responder) 15 | 4. TCP port scanning. Typically from [NMap](https://github.com/nmap/nmap) 16 | 5. Rogue ipv6 dhcp server detection. Typically from [MITM6](https://github.com/fox-it/mitm6) 17 | 18 | The next attacks that I plan on adding detection for are 19 | 1. UDP port scanning. Typically from [NMap](https://github.com/nmap/nmap) 20 | 21 | If there are other network based attacks that you would like to see me add support for please feel free to reach out by opening an issue or pull request. 22 | 23 | # Instructions 24 | ## Quickstart 25 | If you just want to get up and running quickly you can use the following command. With the defaults you will receive a test email when the program starts up. Then you will receive an email each time a new attack is detected. By default if a particular attack hasn't been detected for 10 minutes it is considered over and you will receive a summary email. This time limit can be changed to whatever you want, just see the optional params tables below. If you didn't specify that you do NOT want a startup email but you still didn't receive it there is likely something wrong with the email info you provided. The canarypi log file should contain some information on what went wrong. 26 | 27 | If you use the quickstart command below the logs will be located in /var/lib/docker/volumes/canary_logs/_data/. These will contain a more detail history of the attacks that can be used for forensics. 28 | 29 | The other volume /var/lib/docker/volumes/canary_logs/_data/ is just used for temporary files where attack info is stored during an attack. When the attack is over the files in this folder are read into a summary and then deleted. You should use the logs, not these files, to look up attack history info. 30 | 31 | Note all times reported by Canarypi are currently in UTC 32 | 33 | ``` 34 | docker run -dit --net=host \ 35 | --restart unless-stopped \ 36 | -e EMAIL_SENDER='address used to send alert emails' \ 37 | -e EMAIL_SENDER_PASSWORD='password for account used to send emails' \ 38 | -e EMAIL_RECIPIENT='address to receive alert emails' \ 39 | -v canary_logs:/usr/src/app/logs \ 40 | -v canary_attacks:/usr/src/app/attacks \ 41 | macmondev/canarypi:latest 42 | ``` 43 | 44 | ## Advanced Usage 45 | There are a lot of optional params that can be passed to the container. Just see the parameter tables below. Any of the params can be added to the docker startup command using 46 | 47 | ```-e paramname='paramvalue'``` 48 | 49 | For instance adding this would disable NBNS spoof detection 50 | 51 | ```-e DISABLE_NBNS_SCANNING='True'``` 52 | 53 | You don't need to wrap all of the param values in single quotes but it's a good idea, especially if the values contain special characters. 54 | 55 | Specific logging levels can be set for console, file, and syslog. This allows for logging as much, or as little information, as you want. Console log settings control what will go into the docker logs. File log settings control what will go into a daily rotating log file on disk. Syslog log settings are useful for sending logs to a SIEM or other central logging system. 56 | 57 | ## Using Docker Compose 58 | [Docker compose](https://docs.docker.com/compose/) can be a nice clean way to manage docker containers. You can put your CanaryPi settings into a docker-compose file to make updating to new versions super simple. Here are some quickstart docker-compose setup instructions. 59 | 60 | 1. [Install docker compose](https://docs.docker.com/compose/install/) 61 | 2. Create a folder named CanaryPi to hold your docker-compose file. 62 | 3. Create a docker-compose.yml file in the new folder. It should look similar to this. You can use any optional param from the tables below just like you can in a regular docker run command. Just add them to the environment section. Note this example stores the password in the docker-compose file. It's better to use [docker secrets](https://docs.docker.com/engine/swarm/secrets/) but takes a little bit more setup time. 63 | ``` 64 | Version: '3' 65 | services: 66 | canarypi: 67 | image: macmondev/canarypi:latest 68 | container_name: canarypi 69 | restart: unless-stopped 70 | environment: 71 | - EMAIL_SENDER='address used to send alert emails' 72 | - EMAIL_SENDER_PASSWORD='password for account used to send emails' 73 | - EMAIL_RECIPIENT='address to receive alert emails' 74 | volumes: 75 | - canary_logs:/usr/src/app/logs 76 | - canary_attacks:/usr/src/app/attacks 77 | network_mode: 'host' 78 | 79 | volumes: 80 | canary_logs: 81 | canary_attacks: 82 | ``` 83 | 4. Now to start the container just cd into the same folder as the docker-compose.yml file and type ```docker-compose up -d``` and the container will be spun up in the background with the settings you defined. 84 | 5. To update to a new version of CanaryPi just change back into the same folder and run these commands 85 | * ```docker-compose down``` stops the container 86 | * ```docker-compose pull``` pulls new version 87 | * ```docker-compose up -d``` starts the container with all the same settings using the new version! No need to keep referencing your startup commands and options. 88 | 89 | ## Parameters 90 | ### Detection Related Parameters 91 | #### NBNS Params 92 | | Name | Required | Default Value | Description | 93 | |------|----------|---------------|-------------| 94 | |DISABLE_NBNS_SCANNING|False|False|Set to True if you do not want to try and detect NBNS spoofing| 95 | |BROADCAST_IP|False|224.0.0.252|By default this program will send nbns requests to the multicast address 224.0.0.252 which is not protocol compliant but has worked in testing. For better detection set this value to the actual broadcast address of your network. Like 192.168.1.255| 96 | |NBNS_SLEEP|False|30|Determines how often the network is checked for NBNS spoofing| 97 | 98 | #### LLMNR Params 99 | | Name | Required | Default Value | Description | 100 | |------|----------|---------------|-------------| 101 | |DISABLE_LLMNR_SCANNING|False|False|Set to True if you do not want to try and detect LLMNR spoofing| 102 | |LLMNR_SLEEP|False|30|Determines how often the network is checked for LLMNR spoofing| 103 | 104 | #### mDNS Params 105 | | Name | Required | Default Value | Description | 106 | |------|----------|---------------|-------------| 107 | |DISABLE_MDNS_SCANNING|False|False|Set to True if you do not want to try and detect mDNS spoofing| 108 | |MDNS_SLEEP|False|30|Determines how often the network is checked for mDNS spoofing| 109 | 110 | #### DHCPv6 Params 111 | | Name | Required | Default Value | Description | 112 | |------|----------|---------------|-------------| 113 | |DISABLE_DHCPV6_DETECTION|False|False|Set to True if you do not want to try and detect DHCPv6 servers| 114 | |DHCPV6_WHITELIST|False|null|By default this program will alert you about every DHCPv6 server on your network. If there are valid DHCPv6 servers that you do not want to receive alerts for provide a comma separated list of IPv6 addresses| 115 | |DHCPV6_SLEEP|False|30|Determines how often the network is checked for DHCPv6 servers| 116 | 117 | #### Port Scan Detection Params 118 | | Name | Required | Default Value | Description | 119 | |------|----------|---------------|-------------| 120 | |DISABLE_PORTSCAN_DETECTION|False|False|Set to True if you do not want to try and detect port scan attacks| 121 | |PORTSCAN_TCP_PORTS|False|'21, 25, 80, 110, 143, 443, 445, 465, 3389'|Set to a comma separated list of port numbers. If anything on the network attempts to connect to any of these TCP ports you should be alerted and the connection info logged| 122 | 123 | #### Logging Related Params 124 | | Name | Required | Default Value | Description | 125 | |------|----------|---------------|-------------| 126 | |CONSOLE_LOG_LEVEL|False|warning|Set the docker console logging level. Supported values are debug, info, warning, error, and critical. At least warning must be set to report on detected attacks| 127 | |FILE_LOG_LEVEL|False|warning|Set logging level for the log files. Supported values are debug, info, warning, error, and critical. At least warning must be set to report on detected attacks| 128 | |FILE_LOG_RETENTION|False|30|How many days of rotating log files to keep| 129 | |SYSLOG_ENABLED|False|False|Set to true to enable syslog logging| 130 | |SYSLOG_ADDRESS|False|null|The ip address of the syslog server. Required if SYSLOG_ENABLED is True| 131 | |SYSLOG_PORT|False|514|The port that the syslog server is listening on| 132 | |SYSLOG_TCP|False|False|Set to false for UDP syslog server or True for TCP syslog server| 133 | |SYSLOG_LOG_LEVEL|False|warning|The level of logs to be sent to the syslog server. Supported values are debug, info, warning, error, and critical. At least warning must be set to report on detected attacks| 134 | 135 | #### Email Related Parameters 136 | | Name | Required | Default Value | Description | 137 | |------|----------|---------------|-------------| 138 | |ENABLE_EMAIL_ALERTS|False|True|If set to False the program wont attempt to send any emails| 139 | |ENABLE_EMAIL_STARTUP_TEST|False|True|If set to False the program wont send an email on startup.| 140 | |ENABLE_EMAIL_SERVER_AUTHENTICATION|False|True|If set to false the program wont attempt to login to the smtp server when sending emails. Useful if using an anonymous relay.| 141 | |EMAIL_SERVER_ADDRESS|False|smtp.gmail.com|The email server that the program will connect to for sending email notifications| 142 | |EMAIL_SERVER_PORT|False|587|The smtp port for the email server used to send notifications| 143 | |EMAIL_SERVER_STARTTLS|False|True|Set to False if the email server does not require start tls. Setting this to false sends your credentials in clear text and is considered insecure| 144 | |EMAIL_RECIPIENT|False||The email address that will receive any email notifications. Only required if ENABLE_EMAIL_ALERTS is True| 145 | |EMAIL_SENDER|False||The email address that will be used to send email notifications. Only required if ENABLE_EMAIL_ALERTS is True| 146 | |EMAIL_SENDER_PASSWORD|False||The password for the email address used to send email notifications. Only required if ENABLE_EMAIL_ALERTS is True and ENABLE_EMAIL_SERVER_AUTHENTICATION is set to True| 147 | 148 | #### Misc Parameters 149 | | Name | Required | Default Value | Description | 150 | |------|----------|---------------|-------------| 151 | |ATTACK_TIMEOUT_DURATION|False|600|The amount of inactivity time, in seconds, before an attack is considered over. So by default if an attack was started, but hasn't been detected for over ten minutes, it is considered over. You will be notified based on your logging and email settings.| 152 | 153 | # Credit 154 | I am building on the shoulders of giants. Lots of credit to these guys who I 'borrowed' a lot of code from 155 | 156 | [Scapy](https://scapy.net/) 157 | 158 | [SpoofSpotter](https://github.com/NetSPI/SpoofSpotter) 159 | 160 | [mdns_recon](https://github.com/chadillac/mdns_recon) 161 | 162 | [cciethebeginning](https://cciethebeginning.wordpress.com/2012/01/27/dhcpv6-fake-attack/) 163 | 164 | [The 7ms community for all of their ideas, testing, and feedback](https://7ms.us/) 165 | -------------------------------------------------------------------------------- /alert_handler.py: -------------------------------------------------------------------------------- 1 | # Import modules 2 | import logger 3 | import emailer 4 | import os 5 | import threading 6 | import time 7 | # Regex pattern for only stripping special characters from string 8 | import re; pattern = re.compile('[\W_]+') 9 | from datetime import datetime 10 | 11 | # List to hold alerts in memory before writing them to disk 12 | new_alerts = [] 13 | attack_duration = 0 14 | 15 | # Function for accepting new alert messages from other modules 16 | def new_alert(attack_type, source_ip, source_mac, message): 17 | alert_info = [attack_type, source_ip, pattern.sub('', source_mac), message] 18 | logger.debug(f'new alert received by handler {alert_info}') 19 | new_alerts.append(alert_info) 20 | 21 | # Function for starting mail worked loop 22 | def work(): 23 | # Make sure the attack duration value is valid 24 | try: 25 | attack_duration = int(os.environ['ATTACK_TIMEOUT_DURATION']) 26 | logger.debug(f'Attack duration timeout set to {attack_duration}') 27 | except: 28 | logger.error("Invalid value for Attack Timeout Duration. Must be int") 29 | exit(1) 30 | 31 | logger.debug('Starting alert handler worker thread') 32 | 33 | # Make sure queue folder exists 34 | root = os.getcwd() 35 | attack_file_path = os.path.join(root, 'attacks') 36 | os.makedirs(attack_file_path,exist_ok=True) 37 | 38 | # Setup list to track which attacks have been alerted on 39 | alerted = [] 40 | 41 | while 1: 42 | # Get all existing file names for detected attacks 43 | existing_attack_files = [] 44 | 45 | logger.debug('Fetching current attack file names') 46 | with os.scandir(attack_file_path) as file_names: 47 | for file_name in file_names: 48 | if file_name.is_file(): 49 | existing_attack_files.append(file_name.name) 50 | 51 | # If any notifications haven't been sent for an attack file send it. 52 | if file_name.name not in alerted: 53 | attack_info = file_name.name.split('_') 54 | logger.debug('Sending notification email for new attack') 55 | email_body = f'{attack_info[0]} attack detected from ip {attack_info[1]} mac {attack_info[2]}\n' 56 | email_body += f'See CanaryPi logs for more detail.' 57 | 58 | if emailer.send_email("CanaryPi Attack Detected", email_body): 59 | alerted.append(file_name.name) 60 | 61 | for _ in range(len(new_alerts)): 62 | # Grab the most recent alert out of the new_alerts list 63 | alert = new_alerts.pop() 64 | 65 | # Grab the message from the alert 66 | message = alert.pop() 67 | 68 | # Combine the attack type, source ip, and source mac into a name 69 | attacker_ip = alert[1] 70 | attacker_mac = alert[2] 71 | alert_name = "_".join(alert) 72 | attack_file_name = os.path.join(attack_file_path, alert_name) 73 | 74 | # If new attack_type, source_ip or source_mac then create temporary attack file 75 | if alert_name not in existing_attack_files: 76 | logger.debug('Writing new attack file') 77 | with open(attack_file_name, 'w') as f: 78 | f.write(f'{datetime.now().strftime("%Y/%m/%d %H:%M:%S")} - {message} from ip {attacker_ip} mac {attacker_mac}\n') 79 | existing_attack_files.append(alert_name) 80 | 81 | logger.debug('Sending notification email for new attack') 82 | email_body = f'Attack detected from ip {attacker_ip} mac {attacker_mac}\n' 83 | email_body += f'{message}' 84 | 85 | if emailer.send_email("CanaryPi Attack Detected", email_body): 86 | alerted.append(alert_name) 87 | 88 | # Otherwise append to the existing one. 89 | else: 90 | logger.debug('Appending to attack file') 91 | with open(attack_file_name, 'a') as f: 92 | f.write(f'{datetime.now().strftime("%Y/%m/%d %H:%M:%S")} - {message} from ip {alert[1]} mac {alert[2]}\n') 93 | 94 | # for each on disk que get the last written to time/date 95 | with os.scandir(attack_file_path) as file_names: 96 | for file_name in file_names: 97 | if file_name.is_file(): 98 | # compare most recent activity to attack timeout time 99 | current_time = time.time() 100 | modified_time = os.path.getmtime(file_name) 101 | time_since_modified = current_time - modified_time 102 | 103 | # if time has expired 104 | if time_since_modified > attack_duration: 105 | # Read file from disk and build summary 106 | with open(file_name, 'r') as f: 107 | lines = f.readlines() 108 | 109 | line_parts = lines[0].split("-", 1) 110 | start_time = line_parts[0] 111 | end_time = lines[len(lines) - 1].split("-")[0] 112 | attack_details = line_parts[1].split(" ") 113 | attack_type = attack_details[1] 114 | attacker_ip = attack_details[7] 115 | attacker_mac = attack_details[9] 116 | 117 | # Build email message 118 | message =f'{attack_type} attack has not been detected for {attack_duration} seconds from ip {attacker_ip}, mac {attacker_mac}' 119 | message +=f'Considered over. See details below\n' 120 | message +=f'The attack began at {start_time} and ended at {end_time}\n' 121 | message +=f'There were {str(len(lines))} instances of this attack detected during that timeframe.\n' 122 | message +=f'For more information see the CanaryPi log files\n' 123 | 124 | # Send email alert with summary 125 | logger.debug(f'Sending email because attack is considered over {str(time_since_modified)} is greater than {attack_duration}') 126 | if emailer.send_email("CanaryPi Attack Ended", message): 127 | # Delete file from disk 128 | os.remove(file_name) 129 | 130 | # Remove item from alerted list 131 | alerted.remove(file_name.name) 132 | 133 | # sleep x seconds 134 | # remember this is hard coded 135 | logger.debug('Alert worker sleeping') 136 | time.sleep(10) 137 | 138 | def init(): 139 | logger.debug('Starting alert handler') 140 | 141 | threading.Thread(target=work).start() -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # https://github.com/docker/buildx 4 | 5 | # Notes on setting up build environment on linux 6 | # https://github.com/docker/buildx/issues/57 7 | # The easiest way to setup qemu should be to run docker run --privileged linuxkit/binfmt:v0.7 8 | 9 | usage() 10 | { 11 | echo "This script will push canarypi to dockerhub using a specific version number and update latest tag." 12 | echo "Images will be built for linux/arm64,linux/amd64, and linux/arm/v7" 13 | echo "A version must be specified" 14 | echo "Usage: $0 -v x.x" 15 | } 16 | 17 | if [ "$1" == "" ]; then 18 | usage 19 | exit 20 | fi 21 | 22 | push() 23 | { 24 | if [ "$version" == "" ]; then 25 | usage 26 | exit 27 | fi 28 | 29 | # Create BuildKit 30 | docker buildx create --name canarypi --platform linux/arm64,linux/amd64,linux/arm/v7 31 | docker buildx use canarypi 32 | 33 | # Build multi-platform images 34 | docker buildx build --no-cache --platform linux/arm64,linux/amd64,linux/arm/v7 -t macmondev/canarypi:$version --push . 35 | docker buildx build --no-cache --platform linux/arm64,linux/amd64,linux/arm/v7 -t macmondev/canarypi --push . 36 | 37 | # echo ==== \$ docker buildx imagetools inspect macmondev/canarypi:0.1 38 | docker buildx imagetools inspect macmondev/canarypi 39 | 40 | docker buildx stop canarypi 41 | docker buildx rm canarypi 42 | } 43 | 44 | # Main 45 | while [ "$1" != "" ]; do 46 | case $1 in 47 | -v | --version ) shift 48 | version=$1 49 | push 50 | exit 51 | ;; 52 | -h | --help ) usage 53 | exit 54 | ;; 55 | * ) usage 56 | exit 1 57 | esac 58 | shift 59 | done -------------------------------------------------------------------------------- /dhcpv6.py: -------------------------------------------------------------------------------- 1 | # Test script for detecting rogue dhcpv6 servers 2 | 3 | # Import modules 4 | from scapy.all import * 5 | from netaddr import * 6 | import threading 7 | import random 8 | import string 9 | import logger 10 | import alert_handler 11 | import ipaddress 12 | import os 13 | 14 | import codecs 15 | decode_hex = codecs.getdecoder("hex_codec") 16 | 17 | server_whitelist = [] 18 | 19 | #Function to generate the pieces for a random mac address 20 | def generate_mac_pieces(): 21 | random.seed() 22 | mac11 = str(hex(random.randint(0,255))[2:]) 23 | mac12 = str(hex(random.randint(0,255))[2:]) 24 | mac21 = str(hex(random.randint(0,255))[2:]) 25 | mac22 = str(hex(random.randint(0,255))[2:]) 26 | mac31 = str(hex(random.randint(0,255))[2:]) 27 | mac32 = str(hex(random.randint(0,255))[2:]) 28 | 29 | return [mac11, mac12, mac21, mac22, mac31, mac12] 30 | 31 | def sender(): 32 | try: 33 | SLEEP_TIME = int(os.environ['DHCPV6_SLEEP']) # number of seconds to sleep between requests 34 | except: 35 | logger.error("Invalid dhcpv6 sleep time provided. Must be int") 36 | exit(1) 37 | 38 | # Generate a spoofed mac address rather than trying to fetch the actual one 39 | mac_pieces = generate_mac_pieces() 40 | mac_address = (':').join(mac_pieces) 41 | 42 | # Generate interface ID in EUI-64 format using the spoofed mac address 43 | eui64 = mac_pieces[0] + mac_pieces[1] + ":" + mac_pieces[2] + "ff" + ":" + "fe" + mac_pieces[3] + ":" + mac_pieces[4] + mac_pieces[5] 44 | 45 | # Generates Link-local IPv6 addres in EUI-64 format 46 | ip6_ll_eui64 = "fe80" + "::" + eui64 47 | 48 | # Building and initilizing DHCPv6 SOLICIT packet layers with common parameters 49 | l2 = Ether() 50 | l2.dst = "33:33:00:01:00:02" 51 | 52 | l3 = IPv6() 53 | l3.src = ip6_ll_eui64 54 | l3.dst = "ff02::1:2" 55 | 56 | l4 = UDP() 57 | l4.sport = 546 58 | l4.dport = 547 59 | 60 | sol = DHCP6_Solicit() 61 | random.seed() 62 | sol.trid = random.randint(0,16777215) 63 | 64 | rc = DHCP6OptRapidCommit() 65 | rc.optlen = 0 66 | 67 | opreq = DHCP6OptOptReq() 68 | opreq.optlen = 4 69 | 70 | et= DHCP6OptElapsedTime() 71 | 72 | cid = DHCP6OptClientId() 73 | cid.optlen = 10 74 | cid.duid = (decode_hex("00030001"+str(EUI(mac_address)).replace("-",""))[0]) 75 | 76 | iana = DHCP6OptIA_NA() 77 | iana.optlen = 12 78 | iana.T1 = 0 79 | iana.T2 = 0 80 | 81 | # Assembing the packet 82 | pkt = l2/l3/l4/sol/iana/rc/et/cid/opreq 83 | 84 | while 1: 85 | time.sleep(SLEEP_TIME) 86 | 87 | logger.debug(f'Sending DHCPv6 solicit packet') 88 | sendp (pkt,verbose=0) 89 | 90 | # Handler for incoming DHCPV6 responses 91 | def get_packet(pkt): 92 | if not pkt.getlayer(DHCP6_Advertise): 93 | return 94 | 95 | src_ip = pkt.getlayer(IPv6).src 96 | if src_ip in server_whitelist: 97 | return 98 | 99 | src_mac = pkt.getlayer(Ether).src 100 | 101 | logger.warning(f'A DHCPv6 Server has been detected on your network at {src_ip} - {src_mac}') 102 | alert_handler.new_alert("dhcpv6", src_ip, src_mac, f'DHCPv6 server detected {src_ip}') 103 | 104 | #Function for starting sniffing 105 | def listen(): 106 | sniff(filter="udp and port 546",store=0,prn=get_packet) 107 | 108 | # Main function 109 | def init(): 110 | if os.environ['DHCPV6_WHITELIST'] != "": 111 | try: 112 | whitelist = os.environ['DHCPV6_WHITELIST'].split(",") 113 | 114 | for server in whitelist: 115 | ipaddress.IPv6Address(server.strip()) 116 | server_whitelist.append(server.strip()) 117 | except: 118 | logger.error("Could not parse DHCP whitelist. It must be a comma seperated list of ipv6 addresses") 119 | exit(1) 120 | try: 121 | logger.info("Starting DHCPv6 Response Server...") 122 | threading.Thread(target=listen).start() 123 | logger.info("Starting DHCPv6 Request Thread...") 124 | threading.Thread(target=sender).start() 125 | except: 126 | logger.error("Server could not be started, confirm you're running this as root.\n") 127 | exit(1) -------------------------------------------------------------------------------- /emailer.py: -------------------------------------------------------------------------------- 1 | # Import modules 2 | import os 3 | import smtplib 4 | import logger 5 | 6 | # Function to check all required params 7 | def check_params(): 8 | if os.environ['EMAIL_RECIPIENT'] == '': 9 | logger.error("You must specify an email recipient for email alerts") 10 | return False 11 | if os.environ['EMAIL_SENDER'] == '': 12 | logger.error("You must specify an email address for sending email alerts") 13 | return False 14 | if os.environ['EMAIL_SERVER_ADDRESS'] == '': 15 | logger.error("You must specify an email server address for sending email alerts") 16 | return False 17 | if os.environ['EMAIL_SERVER_PORT'] == '': 18 | logger.error("You must specify an email server port for sending email alerts") 19 | return False 20 | try: 21 | int(os.environ['EMAIL_SERVER_PORT']) 22 | except: 23 | logger.error("Invalid email server port. Must be int") 24 | return False 25 | logger.debug("Email params verified.") 26 | return True 27 | 28 | # Funtion to send messag via email 29 | def send_email(subject, message): 30 | if str(os.environ['ENABLE_EMAIL_ALERTS']).lower()[0] == 't': 31 | if check_params(): 32 | server = smtplib.SMTP(os.environ['EMAIL_SERVER_ADDRESS'], int(os.environ['EMAIL_SERVER_PORT'])) 33 | server.ehlo() 34 | 35 | if str(os.environ['EMAIL_SERVER_STARTTLS']).lower()[0] == 't': 36 | try: 37 | server.starttls() 38 | except smtplib.SMTPHeloError: 39 | logger.error('Error sending mail: The server didn’t reply properly to the HELO greeting.') 40 | server.quit() 41 | return False 42 | except smtplib.SMTPNotSupportedError: 43 | logger.error('The server does not support the STARTTLS extension.') 44 | server.quit() 45 | return False 46 | except RuntimeError: 47 | logger.error('SSL/TLS support is not available to your Python interpreter.') 48 | server.quit() 49 | return False 50 | 51 | if str(os.environ['ENABLE_EMAIL_SERVER_AUTHENTICATION']).lower()[0] == 't': 52 | try: 53 | server.login(os.environ['EMAIL_SENDER'], os.environ['EMAIL_SENDER_PASSWORD']) 54 | except smtplib.SMTPHeloError: 55 | logger.error('Error sending mail: The server didn’t reply properly to the HELO greeting.') 56 | server.quit() 57 | return False 58 | except smtplib.SMTPAuthenticationError: 59 | logger.error('Error sending mail: Credentials refused.') 60 | server.quit() 61 | return False 62 | except smtplib.SMTPNotSupportedError: 63 | logger.error('Error sending mail: The AUTH command is not supported by the server.') 64 | server.quit() 65 | return False 66 | except smtplib.SMTPException: 67 | logger.error('Error sending mail: No suitable authentication method was found.') 68 | server.quit() 69 | return False 70 | 71 | body = '\r\n'.join(['To: %s' % os.environ['EMAIL_RECIPIENT'], 72 | 'From: %s' % os.environ['EMAIL_SENDER'], 73 | 'Subject: %s' % subject, 74 | '', message]) 75 | 76 | try: 77 | server.sendmail(os.environ['EMAIL_SENDER'], os.environ['EMAIL_RECIPIENT'], body) 78 | logger.info('Email sent') 79 | except smtplib.SMTPRecipientsRefused: 80 | logger.error('Error sending mail: Email recipients refused.') 81 | return False 82 | except smtplib.SMTPHeloError: 83 | logger.error('Error sending mail: Server did not respond to HELO greeting.') 84 | return False 85 | except smtplib.SMTPSenderRefused: 86 | logger.error('Error sending mail: The sender address was refused.') 87 | return False 88 | except smtplib.SMTPDataError: 89 | logger.error('Error sending mail: The server replied with an unexpected error code.') 90 | return False 91 | 92 | server.quit() 93 | return True 94 | else: 95 | logger.debug('Email alerts are disabled') 96 | return True -------------------------------------------------------------------------------- /llmnr.py: -------------------------------------------------------------------------------- 1 | # Test script for detecting LLMNR spoofing 2 | 3 | # Import packages 4 | from scapy.all import * 5 | import threading 6 | import random 7 | import string 8 | import logger 9 | import alert_handler 10 | 11 | # Function to generate random hostnames 12 | # Used microsofts DNS naming conventions https://support.microsoft.com/en-us/help/909264/naming-conventions-in-active-directory-for-computers-domains-sites-and 13 | def generate_name(): 14 | string_length = random.randint(2, 63) 15 | letters_and_digits = string.ascii_letters + string.digits + '.' 16 | return ''.join(random.choice(letters_and_digits) for i in range(string_length)) 17 | 18 | # Function to send requests 19 | def sender(): 20 | try: 21 | SLEEP_TIME = int(os.environ['LLMNR_SLEEP']) # number of seconds to sleep between requests 22 | except: 23 | logger.error("Invalid llmnr sleep time provided. Must be int") 24 | exit(1) 25 | 26 | DESTINATION_IP = '224.0.0.252' # multicast address for LLMNR 27 | 28 | while 1: 29 | source_port = random.randint(49152, 65535) 30 | id = random.randint(1, 65535) 31 | query_name = generate_name() 32 | pkt = IP(dst=DESTINATION_IP, ttl=1)/UDP(sport=source_port,dport=5355)/LLMNRQuery(id=id, qr=0, qdcount=1, ancount=0, nscount=0, arcount=0, qd=DNSQR(qname=query_name)) 33 | 34 | logger.debug(f'Sending LLMNR spoofed packet') 35 | send (pkt, verbose=0) 36 | time.sleep(SLEEP_TIME) 37 | 38 | # Handler for incoming responses 39 | def get_packet(pkt): 40 | if not pkt.getlayer(LLMNRResponse): 41 | return 42 | if (pkt.qr == 1) & (pkt.opcode == 0) & (pkt.c == 0) & (pkt.tc == 0) & (pkt.rcode == 0): 43 | # Get the machine name from the LLMNR response 44 | response_name = str(pkt.qd.qname, 'utf-8') 45 | logger.warning(f'A spoofed LMNR response for {response_name} was detected by from host {pkt.getlayer(IP).src} - {pkt.getlayer(Ether).src}') 46 | 47 | # Send message to alert handler 48 | alert_handler.new_alert("llmnr", pkt.getlayer(IP).src, pkt.getlayer(Ether).src, f'LLMNR response for {response_name}') 49 | 50 | #Function for starting sniffing 51 | def listen(): 52 | sniff(filter="udp and port 5355",store=0,prn=get_packet) 53 | 54 | # Main function 55 | def init(): 56 | try: 57 | logger.info("Starting LLMNR UDP Response Server...") 58 | threading.Thread(target=listen).start() 59 | logger.info("Starting LLMNR Request Thread...") 60 | threading.Thread(target=sender).start() 61 | except: 62 | logger.error("Server could not be started, confirm you're running this as root.\n") 63 | exit(1) -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | # Import packages 2 | import os 3 | import logging 4 | import socket 5 | from logging.handlers import TimedRotatingFileHandler 6 | from logging.handlers import SysLogHandler 7 | 8 | # Make sure the log folder exists 9 | os.makedirs('logs',exist_ok=True) 10 | 11 | # Function to verify the logging levels provided were valid 12 | def validate_log_level(level): 13 | if level == 'DEBUG': 14 | return True 15 | elif level == 'INFO': 16 | return True 17 | elif level == 'WARNING': 18 | return True 19 | elif level == 'ERROR': 20 | return True 21 | elif level == 'CRITICAL': 22 | return True 23 | else: 24 | return False 25 | 26 | # Create loggers 27 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 28 | file_logger = logging.getLogger('Rotating Log by Day') 29 | console_logger = logging.getLogger('Log to Console') 30 | syslog_logger = logging.getLogger('Syslog') 31 | 32 | # Verify a valid logging level was provided for console logging 33 | console_log_level = str(os.environ['CONSOLE_LOG_LEVEL']).upper() 34 | if validate_log_level(console_log_level): 35 | console_logger.setLevel(console_log_level) 36 | console_handler = logging.StreamHandler() 37 | console_handler.setFormatter(formatter) 38 | console_logger.addHandler(console_handler) 39 | else: 40 | print("Invalid log level provided for console logging. Must be debug, info, warning, error, or critical") 41 | exit(1) 42 | 43 | # Verify a valid logging level was provided for file logging 44 | file_log_level = str(os.environ['FILE_LOG_LEVEL']).upper() 45 | if validate_log_level(file_log_level): 46 | file_logger.setLevel(file_log_level) 47 | else: 48 | console_logger.critical("Invalid log level provided for file logging. Must be debug, info, warning, error, or critical") 49 | exit(1) 50 | 51 | # Verify a valid logging level was provided for syslog logging 52 | syslog_log_level = str(os.environ['SYSLOG_LOG_LEVEL']).upper() 53 | if validate_log_level(syslog_log_level): 54 | syslog_logger.setLevel(syslog_log_level) 55 | else: 56 | console_logger.critical("Invalid log level provided for syslog logging. Must be debug, info, warning, error, or critical") 57 | exit(1) 58 | 59 | # Setup file logging options 60 | try: 61 | log_retention = int(os.environ['FILE_LOG_RETENTION']) 62 | file_handler = TimedRotatingFileHandler('logs/canary.log', when="d", interval=1, backupCount=log_retention) 63 | file_handler.setFormatter(formatter) 64 | file_logger.addHandler(file_handler) 65 | except: 66 | console_logger.critical("Invalid log retention provided. Must be int") 67 | exit(1) 68 | 69 | # Setup syslog logging options 70 | if os.environ['SYSLOG_ENABLED'][0].lower() == 't': 71 | try: 72 | syslog_port = int(os.environ['SYSLOG_PORT']) 73 | except: 74 | console_logger.critical("Invalid syslog port provided. Must be int") 75 | exit(1) 76 | 77 | if os.environ['SYSLOG_ADDRESS'] == '': 78 | console_logger.critical("You must provide an ip address for the syslog server") 79 | exit(1) 80 | 81 | syslog_server = os.environ['SYSLOG_ADDRESS'] 82 | syslog_socktype = socket.SOCK_DGRAM 83 | 84 | # Check if using TCP for syslog 85 | if os.environ['SYSLOG_TCP'][0].lower() == 't': 86 | syslog_socktype = socket.SOCK_STREAM 87 | 88 | # Connections can fail if using TCP handler 89 | try: 90 | syslog_handler = SysLogHandler(address=(syslog_server, syslog_port), socktype=syslog_socktype) 91 | except: 92 | console_logger.critical("Unable to connect to syslog server") 93 | exit(1) 94 | 95 | syslog_handler.setFormatter(formatter) 96 | syslog_logger.addHandler(syslog_handler) 97 | 98 | # Setup functions for easily logging to console, file, and syslog at the same time. 99 | def debug(message): 100 | console_logger.debug(message) 101 | file_logger.debug(message) 102 | if os.environ['SYSLOG_ENABLED'][0].lower() == 't': 103 | syslog_logger.debug(message) 104 | 105 | def info(message): 106 | console_logger.info(message) 107 | file_logger.info(message) 108 | if os.environ['SYSLOG_ENABLED'][0].lower() == 't': 109 | syslog_logger.info(message) 110 | 111 | def warning(message): 112 | console_logger.warning(message) 113 | file_logger.warning(message) 114 | if os.environ['SYSLOG_ENABLED'][0].lower() == 't': 115 | syslog_logger.warning(message) 116 | 117 | def error(message): 118 | console_logger.error(message) 119 | file_logger.error(message) 120 | if os.environ['SYSLOG_ENABLED'][0].lower() == 't': 121 | syslog_logger.error(message) 122 | 123 | def critical(message): 124 | console_logger.critical(message) 125 | file_logger.critical(message) 126 | if os.environ['SYSLOG_ENABLED'][0].lower() == 't': 127 | syslog_logger.critical(message) 128 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | # Import packages 2 | import os 3 | import logger 4 | import nbns 5 | import llmnr 6 | import mdns 7 | import port_scan 8 | import dhcpv6 9 | import emailer 10 | import alert_handler 11 | 12 | logger.info("Starting up") 13 | 14 | # Initialize the alert handler 15 | alert_handler.init() 16 | 17 | # If enabled send a test email at program startup 18 | if str(os.environ['ENABLE_EMAIL_STARTUP_TEST']).lower()[0] == 't': 19 | logger.debug("Attempting to send startup test email...") 20 | if emailer.send_email("CanaryPi startup", "CanaryPi is starting up. Your email settings have been configured correctly.") == False: 21 | logger.critical("Startup email attempt failed. Killing program.") 22 | exit(1) 23 | 24 | # If enabled start netbios spoof detection 25 | if str(os.environ['DISABLE_NBNS_SCANNING']).lower()[0] != 't': 26 | nbns.init() 27 | 28 | # If enabled start llmnr spoof detection 29 | if str(os.environ['DISABLE_LLMNR_SCANNING']).lower()[0] != 't': 30 | llmnr.init() 31 | 32 | # If enabled start mdns spoof detection 33 | if str(os.environ['DISABLE_MDNS_SCANNING']).lower()[0] != 't': 34 | mdns.init() 35 | 36 | # If enabled start port scan detection 37 | if str(os.environ['DISABLE_PORTSCAN_DETECTION']).lower()[0] != 't': 38 | port_scan.init() 39 | 40 | # If enabled start dhcpv6 detection 41 | if str(os.environ['DISABLE_DHCPV6_DETECTION']).lower()[0] != 't': 42 | dhcpv6.init() -------------------------------------------------------------------------------- /mdns.py: -------------------------------------------------------------------------------- 1 | # Test script for detecting mDNS spoofing 2 | 3 | # Import modules 4 | from scapy.all import * 5 | from collections import deque 6 | import threading 7 | import random 8 | import string 9 | import logger 10 | import alert_handler 11 | 12 | spoofed_names = deque([], 10) 13 | 14 | # Function to generate random hostnames 15 | # Used microsofts DNS naming conventions https://support.microsoft.com/en-us/help/909264/naming-conventions-in-active-directory-for-computers-domains-sites-and 16 | def generate_name(): 17 | string_length = random.randint(2, 63) 18 | letters_and_digits = string.ascii_letters + string.digits + '.' 19 | return ''.join(random.choice(letters_and_digits) for i in range(string_length)) 20 | 21 | # Function to send requests 22 | def sender(): 23 | try: 24 | SLEEP_TIME = int(os.environ['MDNS_SLEEP']) # number of seconds to sleep between requests 25 | except: 26 | logger.error("Invalid mdns sleep time provided. Must be int") 27 | exit(1) 28 | 29 | DESTINATION_IP = '224.0.0.251' # multicast address for mDNS 30 | 31 | while 1: 32 | time.sleep(SLEEP_TIME) 33 | 34 | #source_port = random.randint(49152, 65535) 35 | #id = random.randint(1, 65535) 36 | query_name = generate_name() 37 | 38 | spoofed_names.append(query_name) 39 | spoofed_names.append(query_name+'.') 40 | 41 | pkt = Ether()/IP(dst=DESTINATION_IP, ttl=1)/UDP(sport=5353,dport=5353)/DNS(id=0, qr=0, qdcount=1, ancount=0, nscount=0, rd=1, arcount=0, qd=DNSQR(qname=query_name)) 42 | 43 | logger.debug(f'Sending MDNS spoofed packet') 44 | sendp (pkt, verbose=0) 45 | 46 | # Handler for incoming responses 47 | def get_packet(pkt): 48 | # rfc https://tools.ietf.org/html/rfc6762#section-6 49 | # response source port must be 5353 50 | # destination port must be 5353 51 | # response ip must be 224.0.0.251 or ipv6 FF02::FB 52 | 53 | if not pkt.getlayer(DNS): 54 | return 55 | 56 | src_ip = pkt.getlayer(IP).src 57 | dst_ip = pkt.getlayer(IP).dst 58 | dst_port = pkt.getlayer(IP).dport 59 | src_port = pkt.getlayer(IP).sport 60 | src_mac = pkt.getlayer(Ether).src 61 | 62 | if (pkt.qr == 1) & (pkt.opcode == 0) & (pkt.rcode == 0): 63 | 64 | 65 | # Get the machine name from the mdns response 66 | response_name = str(DNSRR(pkt.an[0]).rrname, 'utf-8') 67 | 68 | if response_name in spoofed_names: 69 | logger.warning(f'A spoofed MDNS response for {response_name} was detected by from host {src_ip} - {src_mac}') 70 | 71 | # Send message to alert handler 72 | alert_handler.new_alert("mdns", src_ip, src_mac, f'MDNS response for {response_name}') 73 | 74 | #Function for starting sniffing 75 | def listen(): 76 | sniff(filter="udp and dst port 5353 and dst host 224.0.0.251",store=0,prn=get_packet) 77 | 78 | # Main function 79 | def init(): 80 | try: 81 | logger.info("Starting MDNS UDP Response Server...") 82 | threading.Thread(target=listen).start() 83 | logger.info("Starting MDNS Request Thread...") 84 | threading.Thread(target=sender).start() 85 | except: 86 | logger.error("Server could not be started, confirm you're running this as root.\n") 87 | exit(1) -------------------------------------------------------------------------------- /nbns.py: -------------------------------------------------------------------------------- 1 | # Test script for detecting netbios name service spoofing 2 | # All the hard work was done by someone else here https://github.com/NetSPI/SpoofSpotter 3 | 4 | # Import packages 5 | from scapy.all import * 6 | import threading 7 | import random 8 | import string 9 | import ipaddress 10 | import logger 11 | import alert_handler 12 | 13 | # Function to generate random hostnames between with a length of 1-15 per netbios spec 14 | # https://en.wikipedia.org/wiki/NetBIOS 15 | def generate_name(): 16 | string_length = random.randint(1, 15) 17 | letters_and_digits = string.ascii_letters + string.digits 18 | return ''.join(random.choice(letters_and_digits) for i in range(string_length)) 19 | 20 | # Function to send requests 21 | def sender(): 22 | try: 23 | SLEEP_TIME = int(os.environ['NBNS_SLEEP']) # number of seconds to sleep between requests 24 | except: 25 | logger.error("Invalid netbios sleep time provided. Must be int") 26 | exit(1) 27 | 28 | BROADCAST_IP = os.environ['BROADCAST_IP'] # get broadcast address from docker environmental variable 29 | 30 | # Verify the provided broadcast address. 31 | if BROADCAST_IP == '': 32 | logger.error("You must supply a value for BROADCAST_IP in the container startup command.") 33 | logger.error("Example -e BROADCAST_IP=192.168.1.255") 34 | exit(1) 35 | 36 | try: 37 | ipaddress.ip_address(BROADCAST_IP) 38 | except: 39 | logger.error(f'{BROADCAST_IP} is not a valid ip address') 40 | exit(1) 41 | 42 | # Send packets in loop 43 | while 1: 44 | query_name = generate_name() 45 | pkt = IP(dst=BROADCAST_IP)/UDP(sport=137, dport='netbios_ns')/NBNSQueryRequest(SUFFIX="file server service",QUESTION_NAME=query_name, QUESTION_TYPE='NB') 46 | logger.debug(f'Sending NBNS spoofed packet') 47 | send (pkt, verbose=0) 48 | time.sleep(SLEEP_TIME) 49 | 50 | # Handler for incoming NBNS responses 51 | def get_packet(pkt): 52 | if not pkt.getlayer(NBNSQueryRequest): 53 | return 54 | if pkt.FLAGS == 0x8500: 55 | # Get the machine name from the NBNS response 56 | response_name = str(pkt.getlayer(NBNSQueryRequest).QUESTION_NAME).split("'")[1].rstrip() 57 | logger.warning(f'A spoofed NBNS response for {response_name} was detected by from host {pkt.getlayer(IP).src} - {pkt.getlayer(Ether).src}') 58 | 59 | # Send message to alert handler 60 | alert_handler.new_alert("nbns", pkt.getlayer(IP).src, pkt.getlayer(Ether).src, f'NBNS response for {response_name}') 61 | 62 | #Function for starting sniffing 63 | def listen(): 64 | sniff(filter="udp and port 137",store=0,prn=get_packet) 65 | 66 | # Main function 67 | def init(): 68 | try: 69 | logger.info("Starting NBNS UDP Response Server...") 70 | threading.Thread(target=listen).start() 71 | logger.info("Starting NBNS Request Thread...") 72 | threading.Thread(target=sender).start() 73 | except: 74 | logger.error("Server could not be started, confirm you're running this as root.\n") 75 | exit(1) 76 | -------------------------------------------------------------------------------- /patchnotes.txt: -------------------------------------------------------------------------------- 1 | v0.8: Updated python modules to clean up warnings from netaddr 2 | 3 | v0.7: Changed detection method for mdns to prevent false possitives 4 | 5 | v0.6: Added rogue dhcpv6 detection 6 | 7 | v0.5: Added mdns detection 8 | 9 | v0.4: Changed port scan detection so each port is considered a different attack and is alerted on 10 | Changed default attack timeout from 1 hour to 10 minutes 11 | Added docker compose notes to readme 12 | 13 | v0.3: Added tcp port scan detection 14 | Othe minor bug fixes and cleanup 15 | 16 | v0.2: Updated Readme to mention UTC, quoting params, and other misc. 17 | Added tracking of succesfully sent alerts so they are sent once per startup of the program. 18 | Added initial syslog support 19 | 20 | v0.1: Initial version. Does NBNS and LLMNR spoof detection, email alerting, and logging -------------------------------------------------------------------------------- /port_scan.py: -------------------------------------------------------------------------------- 1 | # Test script for detecting port scanning 2 | 3 | # Import modules 4 | from scapy.all import * 5 | import threading 6 | import logger 7 | import alert_handler 8 | import netifaces 9 | 10 | sniff_ports = [] 11 | 12 | # Verify that ports supplied by docker env are valid 13 | def verify_tcp_ports(): 14 | try: 15 | tcp_ports = os.environ['PORTSCAN_TCP_PORTS'].split(',') 16 | for port in tcp_ports: 17 | sniff_ports.append(int(port.strip())) 18 | except: 19 | logger.error("Invalid TCP ports provided. Must be ints seperated by commas. Example: '80, 443, 3389'") 20 | exit(1) 21 | 22 | # Function to fetch all local mac address for building filter rules 23 | def get_all_mac_addresses(): 24 | mac_addresses = [] 25 | 26 | # Fetch all local interfaces 27 | interfaces = netifaces.interfaces() 28 | 29 | # Try to fetch mac address for all local interfaces 30 | for interface in interfaces: 31 | try: 32 | addrs = netifaces.ifaddresses(interface) 33 | addr_data = addrs[netifaces.AF_LINK][0] 34 | mac = addr_data['addr'] 35 | mac_addresses.append(mac) 36 | except: 37 | pass 38 | 39 | return mac_addresses 40 | 41 | #Function to build filter fules for mac addresses 42 | def build_mac_string(): 43 | mac_addresses = get_all_mac_addresses() 44 | 45 | mac_string = mac_addresses.pop() 46 | 47 | for mac in mac_addresses: 48 | mac_string += f'or {mac}' 49 | 50 | return mac_string 51 | 52 | # Function to build filter rules for multiple ports 53 | def build_port_string(ports): 54 | port_string = f'dst port {sniff_ports.pop()}' 55 | for p in sniff_ports: 56 | port_string += f' or dst port {p}' 57 | return port_string 58 | 59 | # Handler for incoming responses 60 | def get_packet(pkt): 61 | src_mac = pkt.getlayer(Ether).src 62 | src_ip = pkt.getlayer(IP).src 63 | dst_ip = pkt.getlayer(IP).dst 64 | dst_port = pkt.getlayer(IP).dport 65 | 66 | logger.warning(f'Port scan detected: Mac address {src_mac} with IP address of {src_ip} tried connecting to {dst_ip}:{dst_port} ') 67 | # Send message to alert handler 68 | alert_handler.new_alert(f'portscan{dst_port}', src_ip, src_mac, f'Portscan connection request sent to {dst_ip}:{dst_port}') 69 | 70 | #Function for starting sniffing 71 | def listen(): 72 | # Build sniffing filter. Looks for TCP packets with destination to local mac address and specific destination ports 73 | tcp_filter = f'tcp and ether dst ({build_mac_string()}) and ({build_port_string(sniff_ports)})' 74 | 75 | sniff(filter=tcp_filter,store=0,prn=get_packet) 76 | 77 | # Main function 78 | def init(): 79 | verify_tcp_ports() 80 | try: 81 | logger.info("Starting Port Scan Detection Server...") 82 | threading.Thread(target=listen).start() 83 | except: 84 | logger.error("Port scan detection server could not be started, confirm you're running this as root.\n") 85 | exit(1) 86 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | netaddr==0.7.19 2 | netifaces==0.10.9 3 | scapy==2.4.3 4 | --------------------------------------------------------------------------------