Search code examples
pythonlinuxsocketsnetwork-programmingiptables

Sniff ICMP packets (e.g. ping echo requests) using Python TAP device


I'm implementing a simple userspace networking stack for self-learning purposes. I'm writing it in Python, running it in Linux (Ubuntu 16.04.2 LTS). I'm using a Python TAP device to receive Layer 2 frames (e.g. Ethernet). From there, I extract the headers and process frames according to header fields.

Problem: The TAP device receives several types of frames, however not ICMP packets (e.g. ICMP echo requests). I would like it to receive ICMP echo requests too.

Details: To test the behavior of the stack I'm running ping 10.0.0.4 on the same machine. My Ubuntu environment is running on a VM, and so I've also tried running ping 10.0.0.4 from the host machine (after adding the appropriate entry to the routing table). I always get ICMP echo replies, even though the TAP device sees none of the echo requests:

PING 10.0.0.4 (10.0.0.4): 56 data bytes
64 bytes from 10.0.0.4: icmp_seq=0 ttl=64 time=0.451 ms
64 bytes from 10.0.0.4: icmp_seq=1 ttl=64 time=0.530 ms

Here's the packet handling code (simplified for the purposes of this question):

from pytun import TunTapDevice, IFF_TAP, IFF_NO_PI

tap_dev = TunTapDevice(flags = (IFF_TAP | IFF_NO_PI))
tap_dev.persist(True)
tap_dev.addr = '10.0.0.4'
tap_dev.netmask = '255.255.255.0'
tap_dev.up()

while (1):
    frame = tap_dev.read(1500)
    # extract the Ethernet header from the raw frame 
    # (assume this is working correctly)
    eth_frame_hdr = unpack_eth_hdr(frame)

    # check if it is an IPv4 packet
    if eth_frame_hdr.type == 0x0800:
        ipv4_hdr = unpack_ipv4_hdr(frame)

        # check if an icmp packet
        if ipv4_hdr.proto == 0x01:
            process_icmp(frame)

My diagnosis: I think what's happening is that the Linux kernel is handling the ICMP echo requests directly, and either (1) doesn't even put a packet 'on the wire' or (2) doesn't pass the ICMP packets to userspace.

(Failed) resolution attempts: I've tried several things to get ICMP packets on the TAP device, none of them resulted in the TAP device receiving the ICMP echo requests:

  1. Ignoring ICMP echo handling:

    echo 1 | sudo tee /proc/sys/net/ipv4/icmp_echo_ignore_all

  2. Add an iptables rule to drop ICMP echo requests:

    sudo iptables -I INPUT -p icmp --icmp-type echo-request -j DROP

  3. Add an iptables rule which 'jumps' to the QUEUE target (idea was to pass ICMP packets to userspace):

    sudo iptables -I INPUT -p icmp --icmp-type echo-request -j QUEUE

  4. Use a raw socket as a special case to handle ICMP packets:

    from socket import * icmp_listener_sock = socket(AF_PACKET, SOCK_RAW, IPPROTO_ICMP) icmp_listener_sock.bind((tap_dev.name, IPPROTO_ICMP)) (icmp_ipv4_dgram, snd_addr) = icmp_listener_sock.recvfrom(2048) process_icmp(icmp_ipv4_dgram)

Can you point me to the right way to have the Python TAP device receive the ICMP echo requests?


Solution

  • I reviewed solution attempt 4, and made it work by changing AF_PACKET to AF_INET when creating the raw socket and binding the socket to the address (<ip-address>, 0).

    from socket import * 
    icmp_listener_sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)
    icmp_listener_sock.bind((tap_dev.ip_addr, 0))
    (icmp_ipv4_dgram, snd_addr) = icmp_listener_sock.recvfrom(2048)
    process_icmp(icmp_ipv4_dgram)
    

    Note that this is a workaround, it doesn't answer the question on how to get ICMP packets with a Python TAP device.

    EDIT (and definitive answer):

    I've tried a different approach, which is not listed in the original post. Instead of using the python-pytun package, I've directly opened Linux's TUN/TAP device, using Python-like open() and ioctl() system calls.

    It works very well, and it doesn't require the raw socket workaround to handle ICMP packets.

    In hindsight, this is the approach I should have followed to begin with...

    Here's a minimal example of how to do it:

    import os
    import struct
    from fcntl import ioctl
    
    # ioctl constants
    TUNSETIFF = 0x400454ca
    TUNSETPERSIST = 0x400454cb    
    IFF_TUN = 0x0001
    IFF_TAP = 0x0002
    IFF_NO_PI = 0x1000
    SIOCGIFHWADDR = 0x00008927
    
    try:
        # tap device name
        tap_devname = 'tap0'
    
        # open tap device
        tap_fd = os.open('/dev/net/tun', os.O_RDWR)
    
        # set tap device flags via ioctl():
        #
        # IFF_TUN   : tun device (no Ethernet headers)
        # IFF_TAP   : tap device
        # IFF_NO_PI : do not provide packet information, otherwise we end 
        #             up with unnecessary packet information prepended to 
        #             the Ethernet frame
        ifr = struct.pack("16sH", ("%s" % (tap_devname)), IFF_TAP | IFF_NO_PI)
        ioctl(tap_fd, TUNSETIFF, ifr)
    
        # set device to persistent (if needed be, if not, comment the next line)
        ioctl(tap_fd, TUNSETPERSIST, 1)
    
        print("[INFO] tap device w/ name %s allocated" % (ifr[:16].strip("\x00")))
    
    except Exception as e:
        print("[ERROR] cannot setup tap device (%s)" % (e.message))
    

    Note: after the above, you should do 2 things to make the TAP device operational:

    1. Bring the TAP device up. E.g. in Linux, this could be accomplished with the ip command, as shown below (assumes TAP device name is tap0):

    $ ip link set dev tap0 up

    1. Probably you'll want to associate an IP address with your TAP device. You should add a route table entry so that packets directed to that address are forwarded over the tap0 interface (assumes IP address to associate is 10.0.0.4, and a 255.255.255.0 netmask:

    $ ip route add dev tap0 10.0.0.4/24

    You can do the above in Python too, using Python's subprocess package (see example in my Github).