Skip to content
Ian Knight
Writing a Network Scanner in PowerShell

Writing a Network Scanner in PowerShell

I run a /23 network at home, which is roughly 500 usable addresses across two /24 ranges. Whenever I added a new device or container that needed a static IP, I kept doing the same thing: pick an address, ping it, hope nothing else is using it. That got old pretty quickly.

So I wrote a scanner. The goal was simple: tell me what’s being used, what’s free, and where I should put the next static IP.

The requirements ended up deciding most of how I built it.

It had to work by pasting it into a console. I don’t always have a script directory that I want to manage just to run something once. That ruled out things like exit, because it closes the window, and Read-Host, because it can eat the rest of the pasted text as input. They’re small things, but they’re the kind of small things that make a script annoying to use.

It had to be fast. If you ping 500 addresses one at a time with a 500ms timeout, you’re waiting more than four minutes. No thanks. The scanner runs 128 asynchronous pings at once and waits for the whole batch, which brings a /23 scan down to a few seconds.

It also couldn’t require admin rights. It uses standard .NET classes and built-in PowerShell commands like System.Net.NetworkInformation.Ping, Get-NetNeighbor, and Get-NetIPConfiguration.

The ARP timing trick

One of the more useful things I figured out while building this was when to read the ARP cache. After the ICMP sweep. Not before.

There are plenty of devices that don’t answer pings. Printers, some IoT devices, and anything with a firewall blocking ICMP can all look like they’re offline. But trying to ping them can still cause an ARP request at layer 2, and most of those devices will answer that.

So the scan fills the ARP table as a side effect. Reading it afterward lets the scanner find devices that would’ve looked like free IPs otherwise. Those show up as ARP instead of Ping in the results, so you can see why the scanner thinks they’re there.

Picking the right interface

Multiple network adapters make this a little more interesting. VPNs, virtual switches, Wi-Fi, and Ethernet can all be active at the same time. Just picking the first interface Windows gives you isn’t going to work reliably.

The scanner looks at the route metric and interface metric, adds them together, and uses the lowest value. That’s the same calculation Windows uses when deciding which route to use, so it should end up scanning the network you’re actually on instead of whichever adapter happened to appear first.

If it finds multiple subnets, it prints the commands needed to scan the others.

MAC address hints

The output has a vendor column, but I didn’t want to put a giant OUI database into the script. Those tables are big and eventually go out of date anyway. Instead, there’s a small map for common hypervisors and hardware: VMware, Hyper-V, VirtualBox, QEMU/KVM, Xen, and Raspberry Pi.

For everything else, it looks at the first octet of the MAC address and checks the relevant bits. Bit 0 tells you if it’s multicast. Bit 1 tells you if it’s locally administered, which is useful for spotting randomized MAC addresses on things like phones and laptops. And it doesn’t depend on a vendor database being current.

Suggesting where to put statics

Finding a free IP isn’t that hard. Finding a good one is more useful.

The scanner groups free addresses into ranges instead of printing hundreds of individual addresses. It shows the largest available block and can also find a run of however many consecutive addresses you need.

For a single static IP, it starts at the high end and works down. DHCP pools usually start lower and grow upward, so the high end is generally a better place to put something you don’t want DHCP handing out later.

Guard rails

A /16 has around 65,000 addresses. If you accidentally scan one with a 500ms timeout, you’re going to be waiting a while. It’s also a good way to create a lot more traffic than you intended.

There’s a MaxHosts limit that stops the scan before that happens. When it hits the limit, it gives you the exact command to override it and suggests using a lower timeout. Basically: stop, tell me what happened, and let me decide if I really want to do it.


The whole thing is one file with no dependencies. Paste it in, run Invoke-LabScan, and it gives you a pretty good picture of what’s on the segment. It’s just doing the obvious stuff carefully.