├── .circleci └── config.yml ├── README.md ├── detect_responder.ext └── img └── sample.gif /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | jobs: 4 | build: 5 | machine: true 6 | working_directory: ~/repo 7 | 8 | steps: 9 | - checkout 10 | - run: wget https://osquery-packages.s3.amazonaws.com/deb/osquery_3.2.4_1.linux.amd64.deb 11 | - run: sudo dpkg -i osquery_3.2.4_1.linux.amd64.deb 12 | - run: sudo apt-get update && sudo apt-get install tmux 13 | - run: sudo mkdir /etc/osquery/extensions && sudo chmod 755 /etc/osquery/extensions 14 | - run: sudo cp /home/circleci/repo/detect_responder.ext /etc/osquery/extensions/detect_responder.ext && sudo chmod 755 /etc/osquery/extensions/detect_responder.ext 15 | - run: sudo pip install osquery 16 | - run: sudo touch /etc/osquery/extensions.load 17 | - run: echo '/etc/osquery/extensions/detect_responder.ext' | sudo tee -a /etc/osquery/extensions.load 18 | - run: curl "https://gist.githubusercontent.com/clong/d977895e2cd7cb9dafef56a951741b8e/raw/dfd900f19bffc167421bcecc90f917b7d5894a4f/gistfile1.txt" | sudo tee -a /etc/osquery/osquery.conf 19 | - run: echo '--nodisable_extensions' | sudo tee -a /etc/osquery/osquery.flags 20 | - run: echo '--verbose' | sudo tee -a /etc/osquery/osquery.flags 21 | - run: sudo osqueryctl start 22 | - run: sleep 25 23 | - run: sudo grep -c "registered table plugin detect_responder" /var/log/osquery/* 24 | - run: sudo grep -c "Storing initial results for new scheduled query. detect_responder" /var/log/osquery/* 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Detect Responder (LLMNR, NBT-NS, MDNS poisoner) with osquery 2 | 3 | 4 | 5 | ## Overview 6 | [![Build Status](https://circleci.com/gh/clong/detect-responder.svg?&style=shield)](https://circleci.com/gh/clong/detect-responder/tree/master) 7 | 8 | This repo contains a python-based extension for osquery to detect active instances of Responder or any NBT-NS and LLMNR spoofers/poisoners on the network. 9 | 10 | This extension was developed using osquery's Python bindings from https://github.com/osquery/osquery-python/ 11 | 12 | This extension was written with native Python modules to reduce the need for installing third-party Python libraries on hosts. Although it would have been cleaner and easier to use a library like Scapy, it would require installing it on every host where the extension was used. 13 | 14 | Although many similar tools exist, most of them exist as independent scripts. This extension can take advantage of an existing osquery deployment and can provide network coverage everywhere that you have an osquery agent installed. 15 | 16 | Note: This extension has not been tested on production networks and exists only as a proof-of-concept. 17 | 18 | ## How it works 19 | The extension operates by sending 4 network requests: 20 | 1. A llmnr query for "wpad" to a multicast address 21 | 2. A llmnr query for a randomized 16 character name to a multicast address 22 | 3. A NBT-NS query for "WPAD" to a broadcast address 23 | 4. A NBT-NS query for a randomized 16 character name to a broadcast address 24 | 25 | ## Installation 26 | To begin, the osquery-python package must be installed on the system. The easiest way to install it is via `$ sudo pip install osquery`. 27 | 28 | Create a folder called `/var/osquery/extensions` (MacOS) or `/etc/osquery/extensions` (Linux), `chmod 755` it, and copy detect_responder.ext to that directory. The extension and directory should have the following permissions: 29 | ``` 30 | $ ls -al /var/osquery/extensions 31 | total 3336 32 | drwxr-xr-x 4 root wheel 128 Jun 3 14:37 . 33 | drwxr-xr-x 15 root wheel 480 Apr 17 10:25 .. 34 | -rwxr-xr-x 1 root wheel 5594 Jun 3 14:33 detect_responder.ext 35 | ``` 36 | To configure the extension to load when osquery starts, do one of the following: 37 | * Create a file called `extensions.load` in `/var/osquery` (MacOS) or `/etc/osquery` (Linux) and populate the file with the full path to `detect_responder.ext` 38 | * Edit your flags file and add the following flag: `--extension /path/to/detect_responder.ext` 39 | 40 | ## Usage 41 | Once you have verified that the extension has loaded correctly, you should be able to run `SELECT * FROM detect_responder;`. To test it, run Responder on a different host on the same network. 42 | 43 | To test using osqueryi, run: 44 | `sudo osqueryi --nodisable_extensions --extension /var/osquery/extensions/detect_responder.ext --verbose` 45 | 46 | ## Limitations 47 | * This extension currently only picks the first broadcast address returned by the query. It will support multiple interfaces in the future. 48 | * This extension can be easily fingerprinted and detected. There are no plans to modify it to be harder to profile. 49 | 50 | ## Acknowledgements 51 | * https://blog.netspi.com/identifying-rogue-nbns-spoofers/ 52 | * https://github.com/violentlydave/Poaching-Hunting-in-an-Uncooperative-Environment/ 53 | * https://github.com/SpiderLabs/Responder 54 | -------------------------------------------------------------------------------- /detect_responder.ext: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import socket 4 | import osquery 5 | import random 6 | import string 7 | 8 | @osquery.register_plugin 9 | 10 | class MyTablePlugin(osquery.TablePlugin): 11 | def name(self): 12 | return "detect_responder" 13 | 14 | def columns(self): 15 | return [ 16 | osquery.TableColumn(name="responder_ip", type=osquery.STRING), 17 | osquery.TableColumn(name="protocol", type=osquery.STRING), 18 | osquery.TableColumn(name="query", type=osquery.STRING), 19 | osquery.TableColumn(name="response", type=osquery.STRING), 20 | ] 21 | 22 | # Send a LLMNR request for WPAD to Multicast 23 | def query_llmnr(self, query, length): 24 | # Configure the socket 25 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 26 | sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 32) 27 | sock.settimeout(2) 28 | sock.bind(('0.0.0.0', 0)) 29 | 30 | # Configure the destination address and packet data 31 | mcast_addr = '224.0.0.252' 32 | mcast_port = 5355 33 | if query == "random": 34 | query = ''.join(random.choice(string.lowercase) for i in range(16)) 35 | llmnr_packet_data = "\x31\x81\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00" + chr(length) + query + "\x00\x00\x01\x00\x01" 36 | 37 | # Send the LLMNR query 38 | sock.sendto(llmnr_packet_data, (mcast_addr, mcast_port)) 39 | 40 | # Check if a response was received 41 | while 1: 42 | try: 43 | resp = sock.recvfrom(1024) 44 | # If a response was received, parse the results into a row 45 | if resp: 46 | row = {} 47 | row["responder_ip"] = str(resp[1][0]) 48 | row["query"] = query 49 | row["response"] = str(resp[0][13:(13+length)]) 50 | row["protocol"] = "llmnr" 51 | sock.close() 52 | return row 53 | # If no response, wait for the socket to timeout and close it 54 | except socket.timeout: 55 | sock.close() 56 | return 57 | 58 | def decode_netbios_name(self, nbname): 59 | """ 60 | Return the NetBIOS first-level decoded nbname. 61 | https://stackoverflow.com/questions/13652319/decode-netbios-name-python 62 | """ 63 | if len(nbname) != 32: 64 | return nbname 65 | l = [] 66 | for i in range(0, 32, 2): 67 | l.append(chr(((ord(nbname[i]) - 0x41) << 4) | ((ord(nbname[i+1]) - 0x41) & 0xf))) 68 | return ''.join(l).split('\x00', 1)[0] 69 | 70 | def get_broadcast_addresses(self): 71 | # Use osquery to grab a broadcast address 72 | # TODO: Add checks for multiple broadcast addresses 73 | # TODO: Add checks for no broadcast addresses 74 | instance = osquery.SpawnInstance() 75 | instance.open() 76 | return instance.client.query("SELECT a.broadcast FROM interface_addresses a JOIN interface_details d USING (interface) WHERE address NOT LIKE '%:%' AND address!='127.0.0.1';") 77 | 78 | 79 | def query_nbns(self, query): 80 | # TODO: Loop through multiple broadcast addresses if there is more than one 81 | # Configure the socket 82 | sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 83 | sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 84 | sock.settimeout(2) 85 | sock.bind(('0.0.0.0', 0)) 86 | 87 | # Configure the destination address and packet data 88 | broadcast_address = self.get_broadcast_addresses().response[0]['broadcast'] 89 | port = 137 90 | if query == "WPAD": 91 | # Format WPAD into NetBIOS query format 92 | nbns_query = "\x46\x48\x46\x41\x45\x42\x45\x45\x43\x41\x43\x41\x43\x41\x43\x41\x43\x41\x43\x41\x43\x41\x43\x41\x43\x41\x43\x41\x43\x41\x41\x41" 93 | else: 94 | # Create a query consisting of 16 random characters 95 | query = ''.join(random.choice(string.lowercase) for i in range(16)) 96 | # Encode the query in the format required by NetBIOS 97 | nbns_query = ''.join([chr((ord(c)>>4) + ord('A')) + chr((ord(c)&0xF) + ord('A')) for c in query]) 98 | 99 | # Send the NBNS query 100 | sock.sendto("\x87\x3c\x01\x10\x00\x01\x00\x00\x00\x00\x00\x00\x20" + nbns_query + "\x00\x00\x20\x00\x01", (broadcast_address, port)) 101 | 102 | # Check if a response was received 103 | while 1: 104 | try: 105 | resp = sock.recvfrom(1024) 106 | # If a response was received, parse the results into a row 107 | if resp: 108 | row = {} 109 | row["responder_ip"] = str(resp[1][0]) 110 | row["query"] = str(query).strip() 111 | # Convert the NetBIOS encoded response back to the original query 112 | row["response"] = self.decode_netbios_name(str(resp[0][13:45])).strip() 113 | row["protocol"] = "nbns" 114 | sock.close() 115 | return row 116 | # If no response, wait for the socket to timeout and close it 117 | except socket.timeout: 118 | sock.close() 119 | return 120 | 121 | def generate(self, context): 122 | query_data = [] 123 | query_data += [self.query_llmnr("wpad", 4)] if self.query_llmnr("wpad", 4) is not None else [] 124 | query_data += [self.query_llmnr("random", 16)] if self.query_llmnr("random", 16) is not None else [] 125 | query_data += [self.query_nbns("WPAD")] if self.query_nbns("WPAD") is not None else [] 126 | query_data += [self.query_nbns("random")] if self.query_nbns("random") is not None else [] 127 | return query_data 128 | 129 | if __name__ == "__main__": 130 | osquery.start_extension(name="responder_extension", version="1.0.0") 131 | -------------------------------------------------------------------------------- /img/sample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clong/detect-responder/6e90ea59e8177a67d3e1ff3c77b6d12c5c15aa1e/img/sample.gif --------------------------------------------------------------------------------