Skip to content

Fix SOCK_DGRAM implementation issues - #47

Merged
laixintao merged 1 commit into
laixintao:masterfrom
Paliak:fix-DGRAM
May 25, 2026
Merged

Fix SOCK_DGRAM implementation issues#47
laixintao merged 1 commit into
laixintao:masterfrom
Paliak:fix-DGRAM

Conversation

@Paliak

@Paliak Paliak commented May 17, 2026

Copy link
Copy Markdown
Contributor

Currently there are a few implementation issues preventing use of pingtop without root privs (or access to raw sockets one way or another).

  • receive_one_ping function assumes that the received packet will have the IP header. This will not be the case when using SOCK_DGRAM.
  • receive_one_ping and ping_once incorrectly assume that packet_id will be treated the same by SOCK_RAW and SOCK_DGRAM. When using SOCK_DGRAM, the ICMP header will be validated by the networking stack and the set packet_id will be overwritten with the local source port instead.
  • ping_once checks for SOCK_RAW privs using the uid. This approach will fail for when using CAP_NET_RAW and possibly SUID.

I also added some documentation around the packet parsing in receive_one_ping and renamed RawIcmpEngine to IcmpEngine as it no longer handles just the raw sockets.

Currently there are a few implementation issues preventing use of pingtop
without root privs (or access to raw sockets one way or another).

- receive_one_ping function assumes that the received packet will have
  the ip header. This will not be the case when using SOCK_DGRAM.
- receive_one_ping and ping_once incorrectly assume that packet_id will
  be treated the same by SOCK_RAW and SOCK_DGRAM. When using SOCK_DGRAM,
  the ICMP header will be validated by the networking stack and the set
  packet_id will be overwritten with the local source port instead.
- ping_once checks for SOCK_RAW privs using the uid. This approach will
  fail for when using CAP_NET_RAW and possibly SUID.

Signed-off-by: Paliak <91493239+Paliak@users.noreply.github.com>
@Paliak

Paliak commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

Diff for the icmp engine as the file rename probably makes it harder to review:

diff --git a/raw_icmp.py b/icmp.py
index 545e148..815391f 100644
--- a/raw_icmp.py
+++ b/icmp.py
@@ -10,7 +10,8 @@ import time
 from pingtop.models import PingResult
 
 ICMP_ECHO_REQUEST = 8
-
+ICMP_HEADER_SIZE = 8
+ICMP_ID_HEADER_OFFSET = 4
 
 def checksum(source_bytes: bytes) -> int:
     total = 0
@@ -26,10 +27,11 @@ def checksum(source_bytes: bytes) -> int:
     answer = ~total & 0xFFFF
     return answer >> 8 | ((answer << 8) & 0xFF00)
 
-
 async def receive_one_ping(
-    loop: asyncio.AbstractEventLoop, sock: socket.socket, packet_id: int, timeout: float
-) -> float | None:
+        loop: asyncio.AbstractEventLoop,
+        sock: socket.socket,
+        packet_id: int,
+        timeout: float) -> float | None:
     deadline = loop.time() + timeout
     while True:
         remaining = deadline - loop.time()
@@ -39,17 +41,67 @@ async def receive_one_ping(
             received_packet = await asyncio.wait_for(loop.sock_recv(sock, 1024), remaining)
         except TimeoutError:
             return None
+
         time_received = time.time()
-        icmp_header = received_packet[20:28]
-        _type, _code, _checksum, this_packet_id, _sequence = struct.unpack(
-            "bbHHh", icmp_header
-        )
+
+        # Determine whether we received a full IPv4 packet (with IP header)
+        # or a raw ICMP packet (no IP header) as produced by SOCK_DGRAM on some systems.
+        #
+        # IPv4 first byte:  4 bits Version | 4 bits IHL
+        # - If first_nibble (version) > 0 => looks like an IPv4/IPv6 packet (expect IP header)
+        # - If first byte == 0 (0 for Echo Reply) => not an IP header
+        #
+        #  IPv4 packet header:
+        #
+        #  +---------------------+-------------------------------------------+
+        #  | Byte 0              | Byte 1                                    |
+        #  | V(4) | IHL(4)       | Type of Service                            |
+        #  +---------------------+-------------------------------------------+
+        #  | Bytes 2-3: Total Length                                   |
+        #  +------------------------------------------------------------+
+        #  | ... IP header (IHL*4 bytes) ...                            |
+        #  +------------------------------------------------------------+
+        #  | ICMP header starts here (offset = IHL*4)                   |
+        #  +------------------------------------------------------------+
+        #
+        #  ICMP packet (no IP header):
+        #
+        #  +------------------------------------------------------------+
+        #  | Byte 0: Type (e.g., 0 = Echo Reply)                        |
+        #  +------------------------------------------------------------+
+        #  | Byte 1: Code                                               |
+        #  +------------------------------------------------------------+
+        #  | Bytes 2-3: Checksum                                        |
+        #  +------------------------------------------------------------+
+        #  | Bytes 4-5: Identifier (packet_id)                          |
+        #  +------------------------------------------------------------+
+        #  | Bytes 6-7: Sequence number                                 |
+        #  +------------------------------------------------------------+
+        #  | Bytes 8-: Payload (we store a 'double' timestamp at offset 0 of payload)
+        #  +------------------------------------------------------------+
+
+        # get IP header length (IHL) in 32-bit words -> bytes = IHL * 4
+        icmp_header_offset = (received_packet[0] & 0x0F) * 4
+
+        # Sanity: Ensure we at least have ICMP header (8 bytes) + timestamp (double)
+        if len(received_packet) < (icmp_header_offset + ICMP_HEADER_SIZE + struct.calcsize("d")):
+            continue
+
+        # Sanity: Ensure the checksum checks out before further parsing
+        if checksum(received_packet[icmp_header_offset:]) != 0:
+            continue
+
+        # Strip IP header if present (icmp_header_offset == 0 when no IP header)
+        this_packet_id = struct.unpack_from("!H",
+                                             received_packet,
+                                             offset=(icmp_header_offset + ICMP_ID_HEADER_OFFSET))[0]
         if this_packet_id == packet_id:
-            bytes_size = struct.calcsize("d")
-            time_sent = struct.unpack("d", received_packet[28 : 28 + bytes_size])[0]
+            # unpack the timestamp
+            time_sent = struct.unpack_from("d",
+                                            received_packet,
+                                            offset=(icmp_header_offset + ICMP_HEADER_SIZE))[0]
             return time_received - time_sent
 
-
 async def send_one_ping(
     loop: asyncio.AbstractEventLoop,
     sock: socket.socket,
@@ -57,18 +109,18 @@ async def send_one_ping(
     packet_id: int,
     packet_size: int,
 ) -> None:
-    my_checksum = 0
-    header = struct.pack("bbHHh", ICMP_ECHO_REQUEST, 0, my_checksum, packet_id, 1)
+    # payload: timestamp + padding
     bytes_size = struct.calcsize("d")
-    data = (packet_size - bytes_size) * b"Q"
-    data = struct.pack("d", time.time()) + data
-    my_checksum = checksum(header + data)
-    header = struct.pack("bbHHh", ICMP_ECHO_REQUEST, 0, socket.htons(my_checksum), packet_id, 1)
-    sock.connect((resolved_ip, 1))
-    await loop.sock_sendall(sock, header + data)
+    data = struct.pack("d", time.time()) + (packet_size - bytes_size) * b"Q"
 
+    # initial header with zero checksum
+    # sequence hard coded to 1 since we're not re-using the socket
+    header = struct.pack("!BBHHH", ICMP_ECHO_REQUEST, 0, 0, packet_id, 1)
+    my_checksum = checksum(header + data)
+    header = struct.pack("!BBHHH", ICMP_ECHO_REQUEST, 0, my_checksum, packet_id, 1)
+    return await loop.sock_sendall(sock, header + data)
 
-class RawIcmpEngine:
+class IcmpEngine:
     async def ping_once(
         self, target: str, timeout: float, packet_size: int, flag: int
     ) -> PingResult:
@@ -79,26 +131,32 @@ class RawIcmpEngine:
             return PingResult(success=False, error_message=str(exc))
 
         icmp_proto = socket.getprotobyname("icmp")
+        packet_id = None
+        RTT = None
         try:
-            if os.getuid() != 0:
-                sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, icmp_proto)
-            else:
+            try:
                 sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp_proto)
-        except OSError as exc:
-            return PingResult(success=False, resolved_ip=resolved_ip, error_message=str(exc))
-
-        sock.setblocking(False)
-        packet_id = (os.getpid() & 0xFF00) | (flag & 0x00FF)
-        try:
+                packet_id = (os.getpid() & 0xFF00) | (flag & 0x00FF)
+            except PermissionError:
+                sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, icmp_proto)
+            sock.setblocking(False)
+            # AF_INET requires dest port but since we're sending ICMP it doesn't matter
+            sock.connect((resolved_ip, 0))
+            # Using unprivileged SOCK_DGRAM the kernel will validate/overwrite the ICMP header
+            # This means we do not control the packet_id and it will be set to the port used
+            if packet_id is None:
+                _, port = sock.getsockname()
+                packet_id = port
             await send_one_ping(loop, sock, resolved_ip, packet_id, packet_size)
-            delay = await receive_one_ping(loop, sock, packet_id, timeout)
+            RTT = await receive_one_ping(loop, sock, packet_id, timeout)
         except OSError as exc:
             return PingResult(success=False, resolved_ip=resolved_ip, error_message=str(exc))
         finally:
             sock.close()
-        if delay is None:
+
+        if RTT is None:
             return PingResult(success=False, resolved_ip=resolved_ip)
-        return PingResult(success=True, rtt_ms=delay * 1000, resolved_ip=resolved_ip)
+        return PingResult(success=True, rtt_ms=RTT * 1000, resolved_ip=resolved_ip)
 
     async def _resolve_target(
         self, loop: asyncio.AbstractEventLoop, target: str

@laixintao

Copy link
Copy Markdown
Owner

Diff for the icmp engine as the file rename probably makes it harder to review:

diff --git a/raw_icmp.py b/icmp.py
index 545e148..815391f 100644
--- a/raw_icmp.py
+++ b/icmp.py
@@ -10,7 +10,8 @@ import time
 from pingtop.models import PingResult
 
 ICMP_ECHO_REQUEST = 8
-
+ICMP_HEADER_SIZE = 8
+ICMP_ID_HEADER_OFFSET = 4
 
 def checksum(source_bytes: bytes) -> int:
     total = 0
@@ -26,10 +27,11 @@ def checksum(source_bytes: bytes) -> int:
     answer = ~total & 0xFFFF
     return answer >> 8 | ((answer << 8) & 0xFF00)
 
-
 async def receive_one_ping(
-    loop: asyncio.AbstractEventLoop, sock: socket.socket, packet_id: int, timeout: float
-) -> float | None:
+        loop: asyncio.AbstractEventLoop,
+        sock: socket.socket,
+        packet_id: int,
+        timeout: float) -> float | None:
     deadline = loop.time() + timeout
     while True:
         remaining = deadline - loop.time()
@@ -39,17 +41,67 @@ async def receive_one_ping(
             received_packet = await asyncio.wait_for(loop.sock_recv(sock, 1024), remaining)
         except TimeoutError:
             return None
+
         time_received = time.time()
-        icmp_header = received_packet[20:28]
-        _type, _code, _checksum, this_packet_id, _sequence = struct.unpack(
-            "bbHHh", icmp_header
-        )
+
+        # Determine whether we received a full IPv4 packet (with IP header)
+        # or a raw ICMP packet (no IP header) as produced by SOCK_DGRAM on some systems.
+        #
+        # IPv4 first byte:  4 bits Version | 4 bits IHL
+        # - If first_nibble (version) > 0 => looks like an IPv4/IPv6 packet (expect IP header)
+        # - If first byte == 0 (0 for Echo Reply) => not an IP header
+        #
+        #  IPv4 packet header:
+        #
+        #  +---------------------+-------------------------------------------+
+        #  | Byte 0              | Byte 1                                    |
+        #  | V(4) | IHL(4)       | Type of Service                            |
+        #  +---------------------+-------------------------------------------+
+        #  | Bytes 2-3: Total Length                                   |
+        #  +------------------------------------------------------------+
+        #  | ... IP header (IHL*4 bytes) ...                            |
+        #  +------------------------------------------------------------+
+        #  | ICMP header starts here (offset = IHL*4)                   |
+        #  +------------------------------------------------------------+
+        #
+        #  ICMP packet (no IP header):
+        #
+        #  +------------------------------------------------------------+
+        #  | Byte 0: Type (e.g., 0 = Echo Reply)                        |
+        #  +------------------------------------------------------------+
+        #  | Byte 1: Code                                               |
+        #  +------------------------------------------------------------+
+        #  | Bytes 2-3: Checksum                                        |
+        #  +------------------------------------------------------------+
+        #  | Bytes 4-5: Identifier (packet_id)                          |
+        #  +------------------------------------------------------------+
+        #  | Bytes 6-7: Sequence number                                 |
+        #  +------------------------------------------------------------+
+        #  | Bytes 8-: Payload (we store a 'double' timestamp at offset 0 of payload)
+        #  +------------------------------------------------------------+
+
+        # get IP header length (IHL) in 32-bit words -> bytes = IHL * 4
+        icmp_header_offset = (received_packet[0] & 0x0F) * 4
+
+        # Sanity: Ensure we at least have ICMP header (8 bytes) + timestamp (double)
+        if len(received_packet) < (icmp_header_offset + ICMP_HEADER_SIZE + struct.calcsize("d")):
+            continue
+
+        # Sanity: Ensure the checksum checks out before further parsing
+        if checksum(received_packet[icmp_header_offset:]) != 0:
+            continue
+
+        # Strip IP header if present (icmp_header_offset == 0 when no IP header)
+        this_packet_id = struct.unpack_from("!H",
+                                             received_packet,
+                                             offset=(icmp_header_offset + ICMP_ID_HEADER_OFFSET))[0]
         if this_packet_id == packet_id:
-            bytes_size = struct.calcsize("d")
-            time_sent = struct.unpack("d", received_packet[28 : 28 + bytes_size])[0]
+            # unpack the timestamp
+            time_sent = struct.unpack_from("d",
+                                            received_packet,
+                                            offset=(icmp_header_offset + ICMP_HEADER_SIZE))[0]
             return time_received - time_sent
 
-
 async def send_one_ping(
     loop: asyncio.AbstractEventLoop,
     sock: socket.socket,
@@ -57,18 +109,18 @@ async def send_one_ping(
     packet_id: int,
     packet_size: int,
 ) -> None:
-    my_checksum = 0
-    header = struct.pack("bbHHh", ICMP_ECHO_REQUEST, 0, my_checksum, packet_id, 1)
+    # payload: timestamp + padding
     bytes_size = struct.calcsize("d")
-    data = (packet_size - bytes_size) * b"Q"
-    data = struct.pack("d", time.time()) + data
-    my_checksum = checksum(header + data)
-    header = struct.pack("bbHHh", ICMP_ECHO_REQUEST, 0, socket.htons(my_checksum), packet_id, 1)
-    sock.connect((resolved_ip, 1))
-    await loop.sock_sendall(sock, header + data)
+    data = struct.pack("d", time.time()) + (packet_size - bytes_size) * b"Q"
 
+    # initial header with zero checksum
+    # sequence hard coded to 1 since we're not re-using the socket
+    header = struct.pack("!BBHHH", ICMP_ECHO_REQUEST, 0, 0, packet_id, 1)
+    my_checksum = checksum(header + data)
+    header = struct.pack("!BBHHH", ICMP_ECHO_REQUEST, 0, my_checksum, packet_id, 1)
+    return await loop.sock_sendall(sock, header + data)
 
-class RawIcmpEngine:
+class IcmpEngine:
     async def ping_once(
         self, target: str, timeout: float, packet_size: int, flag: int
     ) -> PingResult:
@@ -79,26 +131,32 @@ class RawIcmpEngine:
             return PingResult(success=False, error_message=str(exc))
 
         icmp_proto = socket.getprotobyname("icmp")
+        packet_id = None
+        RTT = None
         try:
-            if os.getuid() != 0:
-                sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, icmp_proto)
-            else:
+            try:
                 sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp_proto)
-        except OSError as exc:
-            return PingResult(success=False, resolved_ip=resolved_ip, error_message=str(exc))
-
-        sock.setblocking(False)
-        packet_id = (os.getpid() & 0xFF00) | (flag & 0x00FF)
-        try:
+                packet_id = (os.getpid() & 0xFF00) | (flag & 0x00FF)
+            except PermissionError:
+                sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, icmp_proto)
+            sock.setblocking(False)
+            # AF_INET requires dest port but since we're sending ICMP it doesn't matter
+            sock.connect((resolved_ip, 0))
+            # Using unprivileged SOCK_DGRAM the kernel will validate/overwrite the ICMP header
+            # This means we do not control the packet_id and it will be set to the port used
+            if packet_id is None:
+                _, port = sock.getsockname()
+                packet_id = port
             await send_one_ping(loop, sock, resolved_ip, packet_id, packet_size)
-            delay = await receive_one_ping(loop, sock, packet_id, timeout)
+            RTT = await receive_one_ping(loop, sock, packet_id, timeout)
         except OSError as exc:
             return PingResult(success=False, resolved_ip=resolved_ip, error_message=str(exc))
         finally:
             sock.close()
-        if delay is None:
+
+        if RTT is None:
             return PingResult(success=False, resolved_ip=resolved_ip)
-        return PingResult(success=True, rtt_ms=delay * 1000, resolved_ip=resolved_ip)
+        return PingResult(success=True, rtt_ms=RTT * 1000, resolved_ip=resolved_ip)
 
     async def _resolve_target(
         self, loop: asyncio.AbstractEventLoop, target: str

Thanks so much! I need some time to read this

@laixintao
laixintao merged commit 1be7913 into laixintao:master May 25, 2026
0 of 3 checks passed
@laixintao

Copy link
Copy Markdown
Owner

merged, thanks

@Paliak
Paliak deleted the fix-DGRAM branch May 29, 2026 19:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants