Introducing Arpy, the silent IP address guesstimator

22nd November 2019

Arpy started mostly as an excuse to play with Scapy, as well as to address the need to (silently) find a spare IP address on a network. Arpy is a vaguely useful utility which largely automates the process of watching Wireshark or tcpdump for ARP traffic to identify gaps in the local subnet. Simply start it up, leave it running for a period of time, and when you come back you'll get a nice prediction of some IP addresses worth trying - less than reliable - but also an idea of the size of the network, and how many addresses appear to be occupied.

Naturally there's a line. For anything intelligent, I'd encourage you to look at Responder and CrackMapExec, but if all you want to do is watch ARP packets float by then boy do I have a Python tool for you!

Credit to mez-0 for contributing his logging function and prettifying to make the output legible and "demonochromed" and Scapy whose example I extended and fucked with.

Running Arpy

If you don't have scapy already, you will need to install it with a swift python -m pip install scapy. If you don't have pip, you have bigger problems. Have a look at the pip site for help. get-pip.py, linked on the previous site, is probably the easiest way to install.

➜  arpy git:(master) ✗ sudo ./arpy.py
Listening on None
Capture filter is currently set to arp
Listening for ARP traffic

[01/06/20, 12:09:38] >> 00:[redacted] - 192.168.0.1
[01/06/20, 12:10:04] >> 00:[redacted] - 192.168.0.147
[01/06/20, 12:10:04] >> 00:[redacted] - 192.168.0.2
+------------------------------+
|          Statistics          |
+------------------------------+
 -      Subnet range is 146 
 -      Determining subnets...
 -      The captured ip addresses range from 192.168.0.1 to 192.168.0.147
 -      Based on a sample of 3 captured ip addresses
 -      Estimated subnet size: /24
 -      The network is within a public address range
 -      Suspected subnet: 192.168.0.0/24
 -      Network address: 192.168.0.0
 -      Broadcast address: 192.168.0.255
 -      The network range is 256 address(es)
 -      There appear to be 253 empty address(es)
 -      Try:
 -        192.168.0.1
 -        192.168.0.2
 -        192.168.0.3
 -        192.168.0.4
 -        192.168.0.5
 -        192.168.0.6
 -        192.168.0.7
 -        192.168.0.8
 -        192.168.0.9
 -        192.168.0.10

Building Arpy

So a library which I'd always seen referenced but never used myself was Scapy. As a project, this barely scratches the surface - Scapy is a really wonderful toolkit, allowing you to do literally anything you want to a packet - lawful or not. This is fantastic, as so many actual implemented protocols don't follow their guidelines and RFCs, so for generating and receiving packets, it's really without comparison. To further it's mythical status, it's also got really good documentation, (aside from some newer features), and some really solid examples. This project makes use of its listening features, but if you're not familiar with Scapy, its really worth playing around with it, building packets in a way few other libraries allow you to.

The core of Arpy is based on a sample from Scapy of an ARP network sniffer, with the essential part below:

#! /usr/bin/env python
from scapy.all import *

def arp_monitor_callback(pkt):
    if ARP in pkt and pkt[ARP].op in (1,2): #who-has or is-at
        return pkt.sprintf("%ARP.hwsrc% %ARP.psrc%")

sniff(prn=arp_monitor_callback, filter="arp", store=0)

There's some weird stuff going on with "sprintf", but looking at the code you can see the basics of how one starts a sniffer and sets a filter. The parameter "prn" is a method which is called every time a packet is detected. In Arpy, this method is used to decide whether the packet is ARP, CDP, or potentially other captured protocols which we can use to find an IP address, and handle it appropriately. Optionally, we also have our interface. Scapy, by default I believe, listens to all interfaces and threw a proper shitfit when it found my VPN tunnel, so optionally one can specify the interface.

Now this is all fantastic. The main change I made was to switch to the new (as of verion 2.4.3) AsyncSniffer. There wasn't much documentation on this at the time, but from what I can see in the code it's exactly the same as Sniffer. Except it's asynchronous. Which is nice cause now we don't have to stop and start the sniffer to review packets or whatever, we can just leave it going.

I ended up with the following implementation:

Set the listener:

if interface == None:
    sniffo = AsyncSniffer(prn=monitor_callback, filter=capture_filter, store=0)
else:
    sniffo = AsyncSniffer(prn=monitor_callback, filter=capture_filter, store=0, iface=interface)

The monitor_callback function is then able to verify the returned protocol and run further functions based on that. At time of writing, arpy includes just ARP, but conceivably could be expanded to include CDP and other leaky protocols.

Set the monitor_callback for arp:

def arp_mon(packet):
    if ARP in packet and packet[ARP].op in (1,2):
        if not (packet.sprintf("%ARP.hwsrc%") in found_hosts) and not (packet.sprintf("%ARP.psrc%") in ignore) and not (packet.sprintf("%ARP.pdst%") in ignore):
            found_hosts[packet.sprintf("%ARP.hwsrc%")] = packet.sprintf("%ARP.psrc%")
            return logger.green.fg(packet.sprintf("%ARP.hwsrc% - %ARP.psrc%"))
        else:
            if verbose:
                logger.yellow.fg("Ignoring previously discovered host")

Arpy generously includes a neat little keyboard interrupt feature if you're getting bored and need your terminal back.

So if you're looking for a bit of a reference, I hope you find something helpful within Arpy. You have my apologies for the ugly bit of mathematics in the middle - beyond working out the range and then running exponents til larger than both range and highest value, I couldn't think of a neat "best-effort" yet still compact way of approximating the size of a subnet. I ended up using the following as a bodge:

This section can be found here on GitHub

# spoopy stuff
exponent = 0
estimated_subnet_size = 1
cidr = 32
chk = True
while chk:
    # if 2^x is less than the range of ip address
    # and if the maximum proposed range is still 
    # lower than the highest ip address
    network = ipaddress.ip_network(str(
	ipaddress.IPv4Address(numeric_hosts[0]))+"/"+str(cidr), strict=False)
    if (2 ** exponent) < subnet_range or final_host not in network
        estimated_subnet_size = 2 ** exponent
        exponent = exponent + 1
        cidr = cidr - 1
    else:
        chk = False

Due to the fractious nature of networking, there's a few miscellaneous errors you will likely encounter. I've tried to catch as many as I saw, so if you get shouted at by the program, I'm sorry but I hope it helps.

There are some other oddities - it loves "well, technically it's free" as when on 172.x domains. My philosophy while building this was "get something good enough". If you have any improvements or new features you'd like to add, please send me an issue on GitHub and I'll have a look!