Jaroslav Ištvan
← Zpět na projekty

IPK — L2/L3 síťový scanner

C++20Raw socketsARP / NDP / ICMP

Co to dělá

Síťový skener pracující na vrstvách L2 a L3 - najde aktivní zařízení v zadané podsíti a vrátí jejich MAC a IP adresy, ať už jde o IPv4 nebo IPv6. Je to čistě CLI nástroj, výsledky se vypisují přímo do terminálu. Kvůli práci s raw sockety potřebuje běžet s sudo.

Sítě a subnety

Každé zařízení připojené k síti má IP adresu, ale nepřiděluje se libovolně - adresní prostor je rozkrájený na podsítě. Zápis 192.168.1.0/24 říká, že prvních 24 bitů adresy určuje síť a zbylých 8 konkrétní zařízení v ní. Z toho plyne 256 adres, z nichž první patří síti a poslední broadcastu, takže na zařízení zbývá 254.

Není to nic exotického: vlastní podsíť má doma každý. Notebook, telefon i televize dostaly od routeru adresu se stejnými prvními 24 bity a liší se posledním číslem - proto spolu můžou mluvit napřímo.

Právě tohle rozdělení je důvod, proč skener kombinuje dva druhy protokolů. Zařízení uvnitř téže podsítě se domlouvají přímo přes MAC adresy na linkové vrstvě (L2). Cokoliv mimo ni musí přes router a řeší se až na síťové vrstvě (L3).

Pro skener je podsíť vymezením práce: „projdi 10.0.1.0/24“ znamená vyzkoušet 254 adres. U IPv6 platí totéž, jen v jiném měřítku - běžná domácí podsíť /64 obsahuje přes 18 trilionů adres. Projít je nelze ani teoreticky, takže se u IPv6 skenují jen malé rozsahy nebo konkrétní adresy.

Použité protokoly a proč zrovna ony

  • ARP (IPv4, L2) — otázka „kdo má tuhle IP adresu?“ rozeslaná všem v segmentu. Kdo ji má, ohlásí se svou MAC adresou. Funguje jen v lokální podsíti, zato odpovídá skoro vždy: zařízení ARP potřebují k běžnému provozu, takže ho nemůžou ignorovat.
  • ICMP Echo (IPv4, L3) — obyčejný ping. Dosáhne i za router, ale firewally ho běžně zahazují, takže mlčení neznamená, že tam zařízení není.
  • NDP (IPv6) — IPv6 žádné ARP nemá, jeho roli přebírá Neighbor Discovery nad ICMPv6. Místo broadcastu se ptá multicastem na skupinu odvozenou z hledané adresy.
  • ICMPv6 Echo (IPv6) — obdoba pingu pro IPv6.

Smysl kombinace: každý protokol vidí něco jiného. ARP a NDP dají MAC adresu a jsou spolehlivé, ale nedosáhnou za hranici segmentu. ICMP dosáhne dál, zato se dá snadno odfiltrovat. Zkoušet obojí naráz dá úplnější obrázek - a ve výstupu je pak vidět, který z nich uspěl. Proto taky ScanResult drží čtyři nezávislé příznaky místo jediného „našel / nenašel“.

Vstup a výstup

Jeden příkaz, jedna podsíť, seznam nalezených hostů. Tenhle výstup je skutečný záznam z testování na domácí síti:

Vstup
sudo ./ipk-L2L3-scan -i enp0s3 -s 10.0.1.48/24
Výstup
Scanning ranges:
10.0.1.0/24 254

10.0.1.1 arp FAIL, icmpv4 FAIL
10.0.1.11 arp OK (be-87-5a-ad-ea-55), icmpv4 OK
10.0.1.18 arp OK (60-cf-84-62-68-59), icmpv4 FAIL
10.0.1.19 arp OK (c8-8a-d8-0a-cb-2c), icmpv4 OK
10.0.1.39 arp OK (70-54-b4-8d-73-7b), icmpv4 OK
10.0.1.48 arp FAIL, icmpv4 OK
10.0.1.138 arp OK (24-e4-ce-60-de-d1), icmpv4 OK

10.0.1.18 je fyzický stroj, na kterém běžela testovací VM (proto selhává ICMP), 10.0.1.48 je VM samotná (proto selhává ARP) - obojí odpovídá očekávání, ne chybě.

Architektura

Dvě abstrakce dělají hlavní logiku prakticky protokolově nezávislou - a právě na nich stojí čitelnost celého projektu.

Factory Method — Subnet::parse()

Parsování podsítě vrací buď IPv4Subnet, nebo IPv6Subnet, ale volající kód pracuje jen s abstraktní Subnet - nemusí nikde větvit podle verze IP.

utils/subnet.hpp
class Subnet {
public:
    virtual ~Subnet() = default;
    virtual std::string toString() const = 0;
    virtual uint64_t hostCount() const = 0;
    virtual std::unique_ptr<IPGenerator> getGenerator() const = 0;
    static std::unique_ptr<Subnet> parse(const std::string& input);
    // ...vrací IPv4Subnet nebo IPv6Subnet, volající to nemusí řešit
};

Polymorfismus — NetworkScanner

ARP, ICMPv4, NDP a ICMPv6 mají naprosto odlišné pakety, ale všechny stojí za stejným rozhraním: pošli požadavek, přijmi odpověď, řekni jestli umíš danou IP verzi.

scanner/network_scanner.hpp
class NetworkScanner {
public:
    virtual ~NetworkScanner() = default;
    virtual void sendRequest(const std::string& ip) = 0;
    virtual void receiveResponse(std::unordered_map<std::string, ScanResult>& results) = 0;
    virtual int getSocket() const = 0;
    virtual bool supports(const std::string& ip) const = 0;
};
// implementují: ARPScanner, ICMPv4Scanner, NDPScanner, ICMPv6Scanner

Výsledek: main() neví nic o ARP/ICMP/NDP paketech, jenom skládá dohromady podsítě, skenery a výsledky.

main.cpp
int main(int argc, char** argv) {
    std::signal(SIGINT, handle_signal);
    std::signal(SIGTERM, handle_signal);

    Arguments args = processArguments(argc, argv);
    optionalDisplayHelp(args);
    optionalDisplayInterfaces(args);

    std::vector<std::unique_ptr<Subnet>> subnets = getSubnets(args);
    displayScanningRanges(subnets);

    std::unordered_map<std::string, ScanResult> results;
    std::vector<std::unique_ptr<NetworkScanner>> scanners = initializeScanners(args);

    sendAllRequestsLazy(subnets, scanners, results);
    collectResponses(scanners, results, args.timeout);

    displayResultsLazy(subnets, results);

    return static_cast<int>(ExitCode::SUCCESS);
}

Líné generování adres

Podsíť /16 má 65 534 adres, /8 přes šestnáct milionů. Kdyby si program nejdřív vyrobil seznam všech, zaplatil by za to pamětí i čekáním dřív, než odešle první paket.

utils/ip_generator.hpp
class IPGenerator {
public:
    virtual ~IPGenerator() = default;
    virtual bool hasNext() const = 0;
    virtual std::string next() = 0;
};

Rozhraní má jen dvě metody a vydává adresy po jedné, takže program nikdy nedrží víc než jednu. Implementace jsou dvě: IPv4Generator inkrementuje 32bitové číslo, IPv6Generator musí řešit přenos přes celé 16bajtové pole.

Použije se dvakrát - při rozesílání požadavků i při výpisu výsledků. Odtud názvy sendAllRequestsLazy() a displayResultsLazy().

Asynchronní skenování

Naivní postup - pošli dotaz, počkej na odpověď, jdi na další adresu - je při skenování k ničemu. Při timeoutu 500 ms na každou z 254 adres by průchod jedné /24 trval přes dvě minuty, i kdyby většina zařízení odpovídala okamžitě.

Program to obrací: nejdřív rozešle požadavky na všechny adresy, teprve pak sbírá odpovědi. Sokety jsou neblokující a čeká se na nich přes select(), takže jedno vlákno obsluhuje všechny protokoly najednou. Každý protokol má vlastní soket - proto má rozhraní NetworkScanner metodu getSocket().

Odpovědi tak chodí v libovolném pořadí a zapisují se do mapy výsledků podle IP adresy. U ICMP k tomu slouží sekvenční číslo požadavku, které se přes mapu seq_to_ip_ páruje zpátky na adresu.

Omezování rychlosti

Rozeslat desetitisíce paketů tak rychle, jak to jen jde, není dobrý nápad. Přeteče odesílací buffer, aktivní prvky můžou provoz vyhodnotit jako útok a část paketů se cestou ztratí. Výsledkem jsou falešně negativní odpovědi - zařízení tam je, ale sken ho nenajde.

Program proto odesílání přiškrcuje jednoduchým omezovačem. Je to vědomý kompromis: o něco pomalejší sken výměnou za výsledek, kterému se dá věřit.

Návratové kódy

U nástroje spouštěného z příkazové řádky je návratový kód často jediné, co se o jeho osudu dozví volající skript. Místo magických čísel je proto v projektu výčtový typ:

utils/exit_code.hpp
enum class ExitCode {
    SUCCESS                = 0,
    ARGUMENT_PARSING_ERROR = 3,
    INTERFACE_LISTING_ERROR = 4,
    SUBNET_PARSING_ERROR   = 5,
    SOCKET_ERROR           = 6,
    INTERFACE_ERROR        = 7,
};

Fatální chyby se hlásí přes std::exit(static_cast<int>(code)), takže je i v kódu na první pohled vidět, co přesně selhalo.

Moduly

SložkaZodpovědnost
/argsParsování a validace přepínačů příkazové řádky
/coreOrchestrace — inicializace skenerů a podsítí, rozeslání požadavků, sběr odpovědí, výpis výsledků
/scannerImplementace jednotlivých protokolů: sestavení paketu, odeslání, rozpoznání platné odpovědi
/utilsPodsítě, generátory adres, struktura výsledku, návratové kódy, obsluha signálů a kontrola systémových volání
/testsJednotkové testy (doctest)
/externdoctest jako jediná externí závislost

Dělení kopíruje tok programu: /args zjistí co, /utils to přeloží na konkrétní adresy, /scanner se ptá sítě a /core to celé řídí. Jediná složka, která ví o raw socketech a hlavičkách paketů, je /scanner.

Třídy

TřídaZodpovědnost
ArgumentParserProjde argv, naplní strukturu Arguments a ověří, že dává smysl
SubnetAbstraktní podsíť — společné rozhraní a tovární metoda parse()
IPv4Subnet / IPv6SubnetKontrola prefixu, normalizace zápisu, výpočet rozsahu a počtu hostů
IPGeneratorAbstraktní zdroj adres vydávaných po jedné
IPv4Generator / IPv6GeneratorInkrementace 32bitového čísla, resp. 16bajtového pole s přenosem
NetworkScannerKontrakt skeneru: supports, sendRequest, receiveResponse, getSocket
ARPScanner / NDPScannerZjištění MAC adresy na druhé vrstvě (IPv4 / IPv6)
ICMPv4Scanner / ICMPv6ScannerOvěření dostupnosti na třetí vrstvě, párování odpovědí přes sekvenční číslo
ScanResultVýsledek pro jednu IP: čtyři nezávislé příznaky a MAC adresa

Tři abstraktní rozhraní a k nim devět konkrétních tříd - to je celý objektový návrh. Přidat pátý protokol znamená napsat jednu třídu dědící z NetworkScanner a zaregistrovat ji v initializeScanners(); nikde jinde se nesahá.

Diagram tříd

Diagram tříd projektu IPK s atributy a metodami: tři abstraktní rozhraní Subnet, IPGenerator a NetworkScanner s konkrétními implementacemi a datovou třídou ScanResultDiagram tříd projektu IPK s atributy a metodami: tři abstraktní rozhraní Subnet, IPGenerator a NetworkScanner s konkrétními implementacemi a datovou třídou ScanResult
Subnet si přes getGenerator() vytváří vlastní IPGenerator, NetworkScanner plní ScanResult. Atributy a metody podle hlaviček v cpp-projects/ipk.

Testování

Automatizované testy (doctest, spuštěné přes make test) pokrývají parsování argumentů, podsítí, generátory IP adres, tovární metodu i orchestraci skeneru pomocí mock objektů.

Ruční testování proběhlo na reálné domácí síti - výsledky skeneru byly porovnány s nmap, ping6 a systémovou tabulkou ip neigh a odpovídaly si, včetně hraničních případů jako lokální VM nebo hostitelský stroj.

GitHub