├── LICENSE ├── README.md ├── net-listeners-callout.py ├── net-listeners-proc-custom.py ├── net-listeners-proc.py ├── net-listeners-unified.py └── pid_inode_map ├── Makefile └── pid_inode_map.c /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Matt Stancliff 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | netstat Rewritten In Python 2 | =========================== 3 | 4 | Welcome to netmatt — a simple `netstat -p |grep LISTEN` replacement in Python 5 | 6 | ## What Are The Files? 7 | 8 | From oldest/worst to newest/best: 9 | 10 | - `net-listeners-callout.py` 11 | - just reformats `netstat` output (requires root) 12 | - `net-listeners-proc.py` 13 | - reads network state and matching commands from `/proc/[pid]/fd/*` directly (requires root) 14 | - `net-listeners-proc-custom.py` 15 | - matches network state to commands by reading `/proc/pid_inode_map` created by kernel module in directory `pid_inode_map` 16 | - `net-listeners-proc-unified.py` 17 | - uses `/proc/pid_inode_map` if exists; otherwise falls back to iterating `/proc/[pid]/fd/*` 18 | - it's the unification of `net-listeners-proc.py` and `net-listeners-proc-custom.py` 19 | - `pid_inode_map` 20 | - Linux kernel module to create proc file `/proc/pid_inode_map` so non-root users can get a listing of which processes own which IP:Port combinations 21 | 22 | ## What's the deetz? 23 | 24 | Full writeup is [over at matt.sh/netmatt](https://matt.sh/netmatt) 25 | -------------------------------------------------------------------------------- /net-listeners-callout.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Output a colorized list of listening addresses with owners. 4 | 5 | This tool parses the output of ``netstat`` directly to obtain the list 6 | of IPv4 and IPv6 addresses listening on tcp, tcp6, udp, and udp6 ports 7 | also with pids of processes responsible for the listening. 8 | 9 | The downside here is to obtain the full command name (netstat truncates 10 | at 20 characters), we need to call ``ps`` again for each pid we have, 11 | which is even more external commands. 12 | 13 | Must be run as root due to netstat needing root for pid to socket mappings. 14 | 15 | See ``net-listeners-proc.py`` for a much faster implementation because it 16 | parses /proc directly and doesn't need to call out to external proceses.""" 17 | 18 | import subprocess 19 | import re 20 | 21 | NETSTAT_LISTENING = "/bin/netstat --numeric-hosts --listening --program --tcp --udp --inet --inet6" 22 | TERMINAL_WIDTH = "/usr/bin/tput cols" # could also be "stty size" 23 | 24 | 25 | class Color: 26 | HEADER = '\033[95m' 27 | OKBLUE = '\033[94m' 28 | OKGREEN = '\033[92m' 29 | WARNING = '\033[93m' 30 | FAIL = '\033[91m' 31 | BOLD = '\033[1m' 32 | UNDERLINE = '\033[4m' 33 | END = '\033[0m' 34 | 35 | 36 | COLOR_HEADER = Color.HEADER 37 | COLOR_OKAY = Color.OKBLUE 38 | COLOR_WARNING = Color.FAIL 39 | COLOR_END = Color.END 40 | 41 | # This should capture: 42 | # 127.0.0.0/8 43 | # 192.168.0.0/16 44 | # 10.0.0.0/8 45 | # 169.254.0.0/16 46 | # 172.16.0.0/12 47 | # ::1 48 | # fe80::/10 49 | # fc00::/7 50 | # fd00::/8 51 | NON_ROUTABLE_REGEX = r"""^((127\.) | 52 | (192\.168\.) | 53 | (10\.) | 54 | (169\.254\.) | 55 | (172\.1[6-9]\.) | 56 | (172\.2[0-9]\.) | 57 | (172\.3[0-1]\.) | 58 | (::1) | 59 | ([fF][eE]80) 60 | ([fF][cCdD]))""" 61 | likelyLocalOnly = re.compile(NON_ROUTABLE_REGEX, re.VERBOSE) 62 | 63 | 64 | def run(thing): 65 | """ Run any string as an async command invocation. """ 66 | # We don't use subprocess.check_output because we want to run all 67 | # processes async 68 | return subprocess.Popen(thing.split(), stdout=subprocess.PIPE) 69 | 70 | 71 | def readOutput(ranCommand): 72 | """ Return array of rows split by newline from previous invocation. """ 73 | stdout, stderr = ranCommand.communicate() 74 | return stdout.decode('utf-8').strip().splitlines() 75 | 76 | 77 | def checkListenersSystemTools(): 78 | # We intentionally don't check the output of these until after they 79 | # all run so they'll likely run in parallel without blocking. 80 | listening = run(NETSTAT_LISTENING) 81 | terminalWidth = run(TERMINAL_WIDTH) 82 | 83 | listening = readOutput(listening) 84 | 85 | try: 86 | cols = readOutput(terminalWidth)[0] 87 | cols = int(cols) 88 | except BaseException: 89 | cols = 80 90 | 91 | # Remove first two header lines 92 | listening = listening[2:] 93 | 94 | # This is slightly ugly, but 'udp' has one column missing in the 95 | # middle so our pid indices don't line up. 96 | grandResult = [] 97 | for line in listening: 98 | parts = line.split() 99 | 100 | # "udp" rows have one less column in the middle, so 101 | # our pid offset is lower than "tcp" rows: 102 | if parts[0].startswith("udp"): 103 | pid = parts[5].split('/')[0] 104 | else: 105 | pid = parts[6].split('/')[0] 106 | 107 | proto = parts[0] 108 | addr = parts[3] 109 | grandResult.append([int(pid), addr, proto]) 110 | 111 | # Build map of pids to names... 112 | # This dict is pid -> completedCommand 113 | processes = {} 114 | for row in grandResult: 115 | pid = row[0] 116 | 117 | # Don't do redundant work. 118 | # We don't expect pid names to change across calls. 119 | if pid not in processes: 120 | processes[pid] = run(f"/bin/ps -p {pid} -o command=") 121 | 122 | # Now generate the result dict of pid -> pidName 123 | processName = {} 124 | for pid in processes: 125 | processName[pid] = readOutput(processes[pid])[0] 126 | 127 | # Print our own custom output header... 128 | proto = "Proto" 129 | addr = "Listening" 130 | pid = "PID" 131 | process = "Process" 132 | print(f"{COLOR_HEADER}{proto:^5} {addr:^25} {pid:>5} {process:^30}") 133 | 134 | # Sort results by pid... 135 | for row in sorted(grandResult, key=lambda x: x[0]): 136 | pid = row[0] 137 | addr = row[1] 138 | proto = row[2] 139 | process = processName[pid] 140 | 141 | # If IP address looks like it could be visible to the world, 142 | # throw up a color. 143 | # Note: due to port forwarding and NAT and other issues, 144 | # this clearly isn't exhaustive. 145 | if re.match(likelyLocalOnly, addr): 146 | colorNotice = COLOR_OKAY 147 | else: 148 | colorNotice = COLOR_WARNING 149 | 150 | output = f"{colorNotice}{proto:5} {addr:25} {pid:5} {process}" 151 | 152 | # Be a polite terminal citizen by limiting our width to user's width 153 | # (colors take up non-visible space, so add it to our col count) 154 | print(output[:cols + len(colorNotice)]) 155 | 156 | print(COLOR_END) 157 | 158 | 159 | if __name__ == "__main__": 160 | checkListenersSystemTools() 161 | -------------------------------------------------------------------------------- /net-listeners-proc-custom.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Output a colorized list of listening addresses with owners. 4 | 5 | This tool parses files in /proc directly to obtain the list 6 | of IPv4 and IPv6 addresses listening on tcp, tcp6, udp, and udp6 ports 7 | also with pids of processes responsible for the listening. 8 | 9 | Instead of traversing every /proc/[pid]/fd/* file, this tool uses 10 | a custom proc file of /proc/pid_inode_map telling us which PIDs have 11 | which inodes. The kernel module implementing /proc/pid_inode_map 12 | can be built in the 'pid_inode_map' directory. 13 | 14 | Using the custom inode map also lets you run this script without 15 | root yet still obtain the full listening socket to pid/command list. 16 | (but, you obviously need to be root at least once to load the module) 17 | """ 18 | 19 | import collections 20 | import subprocess 21 | import codecs 22 | import socket 23 | import struct 24 | import glob 25 | import sys 26 | import re 27 | import os 28 | 29 | TERMINAL_WIDTH = "/usr/bin/tput cols" # could also be "stty size" 30 | 31 | ONLY_LOWEST_PID = False 32 | 33 | # oooh, look, a big dirty global dict collecting all our data without being 34 | # passed around! call the programming police! 35 | inodes = {} 36 | 37 | 38 | class Color: 39 | HEADER = '\033[95m' 40 | OKBLUE = '\033[94m' 41 | OKGREEN = '\033[92m' 42 | WARNING = '\033[93m' 43 | FAIL = '\033[91m' 44 | BOLD = '\033[1m' 45 | UNDERLINE = '\033[4m' 46 | END = '\033[0m' 47 | 48 | 49 | COLOR_HEADER = Color.HEADER 50 | COLOR_OKAY = Color.OKBLUE 51 | COLOR_WARNING = Color.FAIL 52 | COLOR_END = Color.END 53 | 54 | # This should capture: 55 | # 127.0.0.0/8 56 | # 192.168.0.0/16 57 | # 10.0.0.0/8 58 | # 169.254.0.0/16 59 | # 172.16.0.0/12 60 | # ::1 61 | # fe80::/10 62 | # fc00::/7 63 | # fd00::/8 64 | NON_ROUTABLE_REGEX = r"""^((127\.) | 65 | (192\.168\.) | 66 | (10\.) | 67 | (169\.254\.) | 68 | (172\.1[6-9]\.) | 69 | (172\.2[0-9]\.) | 70 | (172\.3[0-1]\.) | 71 | (::1) | 72 | ([fF][eE]80) 73 | ([fF][cCdD]))""" 74 | likelyLocalOnly = re.compile(NON_ROUTABLE_REGEX, re.VERBOSE) 75 | 76 | 77 | def run(thing): 78 | """ Run any string as an async command invocation. """ 79 | # We don't use subprocess.check_output because we want to run all 80 | # processes async 81 | return subprocess.Popen(thing.split(), stdout=subprocess.PIPE) 82 | 83 | 84 | def readOutput(ranCommand): 85 | """ Return array of rows split by newline from previous invocation. """ 86 | stdout, stderr = ranCommand.communicate() 87 | return stdout.decode('utf-8').strip().splitlines() 88 | 89 | 90 | def procListeners(): 91 | """ Wrapper to parse all IPv4 tcp udp, and, IPv6 tcp6 udp6 listeners. """ 92 | 93 | def processProc(name): 94 | """ Process IPv4 and IPv6 versions of listeners based on ``name``. 95 | 96 | ``name`` is either 'udp' or 'tcp' so we parse, for each ``name``: 97 | - /proc/net/[name] 98 | - /proc/net/[name]6 99 | 100 | As in: 101 | - /proc/net/tcp 102 | - /proc/net/tcp6 103 | - /proc/net/udp 104 | - /proc/net/udp6 105 | """ 106 | 107 | def ipv6(addr): 108 | """ Convert /proc IPv6 hex address into standard IPv6 notation. """ 109 | # turn ASCII hex address into binary 110 | addr = codecs.decode(addr, "hex") 111 | 112 | # unpack into 4 32-bit integers in big endian / network byte order 113 | addr = struct.unpack('!LLLL', addr) 114 | 115 | # re-pack as 4 32-bit integers in system native byte order 116 | addr = struct.pack('@IIII', *addr) 117 | 118 | # now we can use standard network APIs to format the address 119 | addr = socket.inet_ntop(socket.AF_INET6, addr) 120 | return addr 121 | 122 | def ipv4(addr): 123 | """ Convert /proc IPv4 hex address into standard IPv4 notation. """ 124 | # Instead of codecs.decode(), we can just convert a 4 byte hex 125 | # string to an integer directly using python radix conversion. 126 | # Basically, int(addr, 16) EQUALS: 127 | # aOrig = addr 128 | # addr = codecs.decode(addr, "hex") 129 | # addr = struct.unpack(">L", addr) 130 | # assert(addr == (int(aOrig, 16),)) 131 | addr = int(addr, 16) 132 | 133 | # system native byte order, 4-byte integer 134 | addr = struct.pack("=L", addr) 135 | addr = socket.inet_ntop(socket.AF_INET, addr) 136 | return addr 137 | 138 | isUDP = name == "udp" 139 | 140 | # Iterate four files: /proc/net/{tcp,udp}{,6} 141 | # ipv4 has no prefix, while ipv6 has 6 appended. 142 | for ver in ["", "6"]: 143 | with open(f"/proc/net/{name}{ver}", 'r') as proto: 144 | proto = proto.read().splitlines() 145 | proto = proto[1:] # drop header row 146 | 147 | for cxn in proto: 148 | cxn = cxn.split() 149 | 150 | # /proc/net/udp{,6} uses different constants for LISTENING 151 | if isUDP: 152 | # These constants are based on enum offsets inside 153 | # the Linux kernel itself. They aren't likely to ever 154 | # change since they are hardcoded in utilities. 155 | isListening = cxn[3] == "07" 156 | else: 157 | isListening = cxn[3] == "0A" 158 | 159 | # Right now this is a single-purpose tool so if inode is 160 | # not listening, we avoid further processing of this row. 161 | if not isListening: 162 | continue 163 | 164 | ip, port = cxn[1].split(':') 165 | if ver: 166 | ip = ipv6(ip) 167 | else: 168 | ip = ipv4(ip) 169 | 170 | port = int(port, 16) 171 | inode = cxn[9] 172 | 173 | # We just use a list here because creating a new sub-dict 174 | # for each entry was noticably slower than just indexing 175 | # into lists. 176 | inodes[int(inode)] = [ip, port, f"{name}{ver}"] 177 | 178 | processProc("tcp") 179 | processProc("udp") 180 | 181 | 182 | def addProcessNamesToInodes(): 183 | """ Read /proc/pid_inode_map to populate inodePidMap """ 184 | 185 | inodePidMap = collections.defaultdict(list) 186 | with open("/proc/pid_inode_map", 'r') as pim: 187 | for line in pim: 188 | parts = line.split() 189 | pid = parts[0] 190 | name = parts[1] 191 | pimInodes = set(parts[2:]) 192 | for inode in pimInodes: 193 | inodePidMap[int(inode)].append(int(pid)) 194 | 195 | for inode in inodes: 196 | if inode in inodePidMap: 197 | for pid in inodePidMap[inode]: 198 | try: 199 | with open(f"/proc/{pid}/cmdline", 'r') as cmd: 200 | # /proc command line arguments are delimited by 201 | # null bytes, so undo that here... 202 | cmdline = cmd.read().split('\0') 203 | inodes[inode].append((pid, cmdline)) 204 | except BaseException: 205 | # files can vanish on us at any time (and that's okay!) 206 | # But, since the file is gone, we want the entire inodes 207 | # entry gone too: 208 | pass # del inodes[inode] 209 | 210 | 211 | def checkListenersCustomProc(): 212 | terminalWidth = run(TERMINAL_WIDTH) 213 | 214 | procListeners() 215 | addProcessNamesToInodes() 216 | tried = inodes 217 | 218 | try: 219 | cols = readOutput(terminalWidth)[0] 220 | cols = int(cols) 221 | except BaseException: 222 | cols = 80 223 | 224 | # Print our own custom output header... 225 | proto = "Proto" 226 | addr = "Listening" 227 | pid = "PID" 228 | process = "Process" 229 | print(f"{COLOR_HEADER}{proto:^5} {addr:^25} {pid:>5} {process:^30}") 230 | 231 | # Could sort by anything: ip, port, proto, pid, command name 232 | # (or even the inode integer if that provided any insight whatsoever) 233 | def compareByPidOrPort(what): 234 | k, v = what 235 | # v = [ip, port, proto, pid, cmd] 236 | # - OR - 237 | # v = [ip, port, proto] 238 | 239 | # If we're not running as root we can't pid and command mappings for 240 | # the processes of other users, so sort the pids we did find at end 241 | # of list and show UNKNOWN entries first 242 | # (because the lines will be shorter most likely so the bigger visual 243 | # weight should be lower in the display table) 244 | try: 245 | # Pid available! Sort by first pid, subsort by IP then port. 246 | return (1, v[3], v[0], v[1]) 247 | except BaseException: 248 | # No pid available! Sort by port number then IP then... port again. 249 | return (0, v[1], v[0], v[1]) 250 | 251 | # Sort results by pid... 252 | for name, vals in sorted(tried.items(), key=compareByPidOrPort): 253 | desc = [f"{pid:5} {' '.join(cmd)}" for pid, cmd in vals[3:]] 254 | port = vals[1] 255 | 256 | try: 257 | # Convert port integer to service name if possible 258 | port = socket.getservbyport(port) 259 | except BaseException: 260 | # If no match, just use port number directly. 261 | pass 262 | 263 | addr = f"{vals[0]}:{port}" 264 | proto = vals[2] 265 | 266 | # If IP address looks like it could be visible to the world, 267 | # throw up a color. 268 | # Note: due to port forwarding and NAT and other issues, 269 | # this clearly isn't exhaustive. 270 | if re.match(likelyLocalOnly, addr): 271 | colorNotice = COLOR_OKAY 272 | else: 273 | colorNotice = COLOR_WARNING 274 | 275 | isFirstLine = True 276 | for line in desc: 277 | if isFirstLine: 278 | output = f"{colorNotice}{proto:5} {addr:25} {line}" 279 | isFirstLine = False 280 | else: 281 | output = f"{' ':31} {line}" 282 | 283 | # Be a polite terminal citizen by limiting our width to user's width 284 | # (colors take up non-visible space, so add it to our col count) 285 | print(output[:cols + (len(colorNotice) if isFirstLine else 0)]) 286 | 287 | if ONLY_LOWEST_PID: 288 | break 289 | 290 | print(COLOR_END) 291 | 292 | 293 | if __name__ == "__main__": 294 | # cheap hack garbage way of setting one option 295 | # if we need more options, obviously pull in argparse 296 | if len(sys.argv) > 1: 297 | ONLY_LOWEST_PID = True 298 | 299 | checkListenersCustomProc() 300 | -------------------------------------------------------------------------------- /net-listeners-proc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Output a colorized list of listening addresses with owners. 4 | 5 | This tool parses files in /proc directly to obtain the list 6 | of IPv4 and IPv6 addresses listening on tcp, tcp6, udp, and udp6 ports 7 | also with pids of processes responsible for the listening. 8 | 9 | Due to permission restrictions on Linux, script must be run as root 10 | to determine which pids match which listening sockets. 11 | 12 | This is also something like: 13 | osqueryi "select po.pid, rtrim(p.cmdline), po.family, po.local_address, po.local_port from process_open_sockets as po JOIN processes as p ON po.pid=p.pid WHERE po.state='LISTEN';" 14 | 15 | """ 16 | 17 | import collections 18 | import subprocess 19 | import codecs 20 | import socket 21 | import struct 22 | import glob 23 | import sys 24 | import re 25 | import os 26 | 27 | TERMINAL_WIDTH = "/usr/bin/tput cols" # could also be "stty size" 28 | 29 | ONLY_LOWEST_PID = False 30 | 31 | # oooh, look, a big dirty global dict collecting all our data without being 32 | # passed around! call the programming police! 33 | inodes = {} 34 | 35 | 36 | class Color: 37 | HEADER = '\033[95m' 38 | OKBLUE = '\033[94m' 39 | OKGREEN = '\033[92m' 40 | WARNING = '\033[93m' 41 | FAIL = '\033[91m' 42 | BOLD = '\033[1m' 43 | UNDERLINE = '\033[4m' 44 | END = '\033[0m' 45 | 46 | 47 | COLOR_HEADER = Color.HEADER 48 | COLOR_OKAY = Color.OKBLUE 49 | COLOR_WARNING = Color.FAIL 50 | COLOR_END = Color.END 51 | 52 | # This should capture: 53 | # 127.0.0.0/8 54 | # 192.168.0.0/16 55 | # 10.0.0.0/8 56 | # 169.254.0.0/16 57 | # 172.16.0.0/12 58 | # ::1 59 | # fe80::/10 60 | # fc00::/7 61 | # fd00::/8 62 | NON_ROUTABLE_REGEX = r"""^((127\.) | 63 | (192\.168\.) | 64 | (10\.) | 65 | (169\.254\.) | 66 | (172\.1[6-9]\.) | 67 | (172\.2[0-9]\.) | 68 | (172\.3[0-1]\.) | 69 | (::1) | 70 | ([fF][eE]80) 71 | ([fF][cCdD]))""" 72 | likelyLocalOnly = re.compile(NON_ROUTABLE_REGEX, re.VERBOSE) 73 | 74 | 75 | def run(thing): 76 | """ Run any string as an async command invocation. """ 77 | # We don't use subprocess.check_output because we want to run all 78 | # processes async 79 | return subprocess.Popen(thing.split(), stdout=subprocess.PIPE) 80 | 81 | 82 | def readOutput(ranCommand): 83 | """ Return array of rows split by newline from previous invocation. """ 84 | stdout, stderr = ranCommand.communicate() 85 | return stdout.decode('utf-8').strip().splitlines() 86 | 87 | 88 | def procListeners(): 89 | """ Wrapper to parse all IPv4 tcp udp, and, IPv6 tcp6 udp6 listeners. """ 90 | 91 | def processProc(name): 92 | """ Process IPv4 and IPv6 versions of listeners based on ``name``. 93 | 94 | ``name`` is either 'udp' or 'tcp' so we parse, for each ``name``: 95 | - /proc/net/[name] 96 | - /proc/net/[name]6 97 | 98 | As in: 99 | - /proc/net/tcp 100 | - /proc/net/tcp6 101 | - /proc/net/udp 102 | - /proc/net/udp6 103 | """ 104 | 105 | def ipv6(addr): 106 | """ Convert /proc IPv6 hex address into standard IPv6 notation. """ 107 | # turn ASCII hex address into binary 108 | addr = codecs.decode(addr, "hex") 109 | 110 | # unpack into 4 32-bit integers in big endian / network byte order 111 | addr = struct.unpack('!LLLL', addr) 112 | 113 | # re-pack as 4 32-bit integers in system native byte order 114 | addr = struct.pack('@IIII', *addr) 115 | 116 | # now we can use standard network APIs to format the address 117 | addr = socket.inet_ntop(socket.AF_INET6, addr) 118 | return addr 119 | 120 | def ipv4(addr): 121 | """ Convert /proc IPv4 hex address into standard IPv4 notation. """ 122 | # Instead of codecs.decode(), we can just convert a 4 byte hex 123 | # string to an integer directly using python radix conversion. 124 | # Basically, int(addr, 16) EQUALS: 125 | # aOrig = addr 126 | # addr = codecs.decode(addr, "hex") 127 | # addr = struct.unpack(">L", addr) 128 | # assert(addr == (int(aOrig, 16),)) 129 | addr = int(addr, 16) 130 | 131 | # system native byte order, 4-byte integer 132 | addr = struct.pack("=L", addr) 133 | addr = socket.inet_ntop(socket.AF_INET, addr) 134 | return addr 135 | 136 | isUDP = name == "udp" 137 | 138 | # Iterate four files: /proc/net/{tcp,udp}{,6} 139 | # ipv4 has no prefix, while ipv6 has 6 appended. 140 | for ver in ["", "6"]: 141 | with open(f"/proc/net/{name}{ver}", 'r') as proto: 142 | proto = proto.read().splitlines() 143 | proto = proto[1:] # drop header row 144 | 145 | for cxn in proto: 146 | cxn = cxn.split() 147 | 148 | # /proc/net/udp{,6} uses different constants for LISTENING 149 | if isUDP: 150 | # These constants are based on enum offsets inside 151 | # the Linux kernel itself. They aren't likely to ever 152 | # change since they are hardcoded in utilities. 153 | isListening = cxn[3] == "07" 154 | else: 155 | isListening = cxn[3] == "0A" 156 | 157 | # Right now this is a single-purpose tool so if process is 158 | # not listening, we avoid further processing of this row. 159 | if not isListening: 160 | continue 161 | 162 | ip, port = cxn[1].split(':') 163 | if ver: 164 | ip = ipv6(ip) 165 | else: 166 | ip = ipv4(ip) 167 | 168 | port = int(port, 16) 169 | inode = cxn[9] 170 | 171 | # We just use a list here because creating a new sub-dict 172 | # for each entry was noticably slower than just indexing 173 | # into lists. 174 | inodes[int(inode)] = [ip, port, f"{name}{ver}"] 175 | 176 | processProc("tcp") 177 | processProc("udp") 178 | 179 | 180 | def appendToInodePidMap(fd, inodePidMap): 181 | """ Take a full path to /proc/[pid]/fd/[fd] for reading. 182 | 183 | Populates both pid and full command line of pid owning an inode we 184 | are interested in. 185 | 186 | Basically finds if any inodes on this pid is a listener we previously 187 | recorded into our ``inodes`` dict. """ 188 | _, _, pid, _, _ = fd.split('/') 189 | try: 190 | target = os.readlink(fd) 191 | except FileNotFoundError: 192 | # file vanished, can't do anything else 193 | return 194 | 195 | if target.startswith("socket"): 196 | ostype, inode = target.split(':') 197 | # strip brackets from fd string (it looks like: [fd]) 198 | inode = int(inode[1:-1]) 199 | inodePidMap[inode].append(int(pid)) 200 | 201 | 202 | def addProcessNamesToInodes(): 203 | """ Loop over every fd in every process in /proc. 204 | 205 | The only way to map an fd back to a process is by looking 206 | at *every* processes fd and extracting backing inodes. 207 | 208 | It's basically like a big awkward database join where you don't 209 | have an index on the field you want. 210 | 211 | Also, due to Linux permissions (and Linux security concerns), 212 | only the root user can read fd listing of processes not owned 213 | by the current user. """ 214 | 215 | # glob glob glob it all 216 | allFDs = glob.iglob("/proc/*/fd/*") 217 | inodePidMap = collections.defaultdict(list) 218 | 219 | for fd in allFDs: 220 | appendToInodePidMap(fd, inodePidMap) 221 | 222 | for inode in inodes: 223 | if inode in inodePidMap: 224 | for pid in inodePidMap[inode]: 225 | try: 226 | with open(f"/proc/{pid}/cmdline", 'r') as cmd: 227 | # /proc command line arguments are delimited by 228 | # null bytes, so undo that here... 229 | cmdline = cmd.read().split('\0') 230 | inodes[inode].append((pid, cmdline)) 231 | except BaseException: 232 | # files can vanish on us at any time (and that's okay!) 233 | # But, since the file is gone, we want the entire fd 234 | # entry gone too: 235 | pass # del inodes[inode] 236 | 237 | 238 | def checkListenersProc(): 239 | terminalWidth = run(TERMINAL_WIDTH) 240 | 241 | procListeners() 242 | addProcessNamesToInodes() 243 | tried = inodes 244 | 245 | try: 246 | cols = readOutput(terminalWidth)[0] 247 | cols = int(cols) 248 | except BaseException: 249 | cols = 80 250 | 251 | # Print our own custom output header... 252 | proto = "Proto" 253 | addr = "Listening" 254 | pid = "PID" 255 | process = "Process" 256 | print(f"{COLOR_HEADER}{proto:^5} {addr:^25} {pid:>5} {process:^30}") 257 | 258 | # Could sort by anything: ip, port, proto, pid, command name 259 | # (or even the fd integer if that provided any insight whatsoever) 260 | def compareByPidOrPort(what): 261 | k, v = what 262 | # v = [ip, port, proto, pid, cmd] 263 | # - OR - 264 | # v = [ip, port, proto] 265 | 266 | # If we're not running as root we can't pid and command mappings for 267 | # the processes of other users, so sort the pids we did find at end 268 | # of list and show UNKNOWN entries first 269 | # (because the lines will be shorter most likely so the bigger visual 270 | # weight should be lower in the display table) 271 | try: 272 | # Pid available! Sort by first pid, subsort by IP then port. 273 | return (1, v[3], v[0], v[1]) 274 | except BaseException: 275 | # No pid available! Sort by port number then IP then... port again. 276 | return (0, v[1], v[0], v[1]) 277 | 278 | # Sort results by pid... 279 | for name, vals in sorted(tried.items(), key=compareByPidOrPort): 280 | attachedPids = vals[3:] 281 | if attachedPids: 282 | desc = [f"{pid:5} {' '.join(cmd)}" for pid, cmd in vals[3:]] 283 | else: 284 | # If not running as root, we won't have pid or process, so use 285 | # defaults 286 | desc = ["UNKNOWN (must be root for global pid mappings)"] 287 | 288 | port = vals[1] 289 | try: 290 | # Convert port integer to service name if possible 291 | port = socket.getservbyport(port) 292 | except BaseException: 293 | # If no match, just use port number directly. 294 | pass 295 | 296 | addr = f"{vals[0]}:{port}" 297 | proto = vals[2] 298 | 299 | # If IP address looks like it could be visible to the world, 300 | # throw up a color. 301 | # Note: due to port forwarding and NAT and other issues, 302 | # this clearly isn't exhaustive. 303 | if re.match(likelyLocalOnly, addr): 304 | colorNotice = COLOR_OKAY 305 | else: 306 | colorNotice = COLOR_WARNING 307 | 308 | isFirstLine = True 309 | for line in desc: 310 | if isFirstLine: 311 | output = f"{colorNotice}{proto:5} {addr:25} {line}" 312 | isFirstLine = False 313 | else: 314 | output = f"{' ':31} {line}" 315 | 316 | # Be a polite terminal citizen by limiting our width to user's width 317 | # (colors take up non-visible space, so add it to our col count) 318 | print(output[:cols + (len(colorNotice) if isFirstLine else 0)]) 319 | 320 | if ONLY_LOWEST_PID: 321 | break 322 | 323 | print(COLOR_END) 324 | 325 | 326 | if __name__ == "__main__": 327 | # cheap hack garbage way of setting one option 328 | # if we need more options, obviously pull in argparse 329 | if len(sys.argv) > 1: 330 | ONLY_LOWEST_PID = True 331 | else: 332 | ONLY_LOWEST_PID = False 333 | 334 | checkListenersProc() 335 | -------------------------------------------------------------------------------- /net-listeners-unified.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ Output a colorized list of listening addresses with owners. 4 | 5 | This is the unification with duplication removed of: 6 | - net-listeners-proc.py 7 | - net-listeners-proc-custom.py 8 | 9 | During development it was useful to have multiple files to test various 10 | approaches at the same time, but each design converged on a common set 11 | of data structures and a common set of approaches to final reporting. 12 | """ 13 | 14 | import collections 15 | import subprocess 16 | import codecs 17 | import socket 18 | import struct 19 | import glob 20 | import sys 21 | import re 22 | import os 23 | 24 | TERMINAL_WIDTH = "/usr/bin/tput cols" # could also be "stty size" 25 | 26 | # oooh, look, a big dirty global dict collecting all our data without being 27 | # passed around! call the programming police! 28 | inodes = {} 29 | 30 | 31 | class Color: 32 | HEADER = '\033[95m' 33 | OKBLUE = '\033[94m' 34 | OKGREEN = '\033[92m' 35 | WARNING = '\033[93m' 36 | FAIL = '\033[91m' 37 | BOLD = '\033[1m' 38 | UNDERLINE = '\033[4m' 39 | END = '\033[0m' 40 | 41 | 42 | COLOR_HEADER = Color.HEADER 43 | COLOR_OKAY = Color.OKBLUE 44 | COLOR_WARNING = Color.FAIL 45 | COLOR_END = Color.END 46 | 47 | # This should capture: 48 | # 127.0.0.0/8 49 | # 192.168.0.0/16 50 | # 10.0.0.0/8 51 | # 169.254.0.0/16 52 | # 172.16.0.0/12 53 | # ::1 54 | # fe80::/10 55 | # fc00::/7 56 | # fd00::/8 57 | NON_ROUTABLE_REGEX = r"""^((127\.) | 58 | (192\.168\.) | 59 | (10\.) | 60 | (169\.254\.) | 61 | (172\.1[6-9]\.) | 62 | (172\.2[0-9]\.) | 63 | (172\.3[0-1]\.) | 64 | (::1) | 65 | ([fF][eE]80) 66 | ([fF][cCdD]))""" 67 | likelyLocalOnly = re.compile(NON_ROUTABLE_REGEX, re.VERBOSE) 68 | 69 | 70 | def run(thing): 71 | """ Run any string as an async command invocation. """ 72 | # We don't use subprocess.check_output because we want to run all 73 | # processes async 74 | return subprocess.Popen(thing.split(), stdout=subprocess.PIPE) 75 | 76 | 77 | def readOutput(ranCommand): 78 | """ Return array of rows split by newline from previous invocation. """ 79 | stdout, stderr = ranCommand.communicate() 80 | return stdout.decode('utf-8').strip().splitlines() 81 | 82 | 83 | def iterateProcNetworkingGatherListeningInodes(): 84 | """ Wrapper to parse all IPv4 tcp udp, and, IPv6 tcp6 udp6 listeners. """ 85 | 86 | def processProc(name): 87 | """ Process IPv4 and IPv6 versions of listeners based on ``name``. 88 | 89 | ``name`` is either 'udp' or 'tcp' so we parse, for each ``name``: 90 | - /proc/net/[name] 91 | - /proc/net/[name]6 92 | 93 | As in: 94 | - /proc/net/tcp 95 | - /proc/net/tcp6 96 | - /proc/net/udp 97 | - /proc/net/udp6 98 | """ 99 | 100 | def ipv6(addr): 101 | """ Convert /proc IPv6 hex address into standard IPv6 notation. """ 102 | # turn ASCII hex address into binary 103 | addr = codecs.decode(addr, "hex") 104 | 105 | # unpack into 4 32-bit integers in big endian / network byte order 106 | addr = struct.unpack('!LLLL', addr) 107 | 108 | # re-pack as 4 32-bit integers in system native byte order 109 | addr = struct.pack('@IIII', *addr) 110 | 111 | # now we can use standard network APIs to format the address 112 | addr = socket.inet_ntop(socket.AF_INET6, addr) 113 | return addr 114 | 115 | def ipv4(addr): 116 | """ Convert /proc IPv4 hex address into standard IPv4 notation. """ 117 | # Instead of codecs.decode(), we can just convert a 4 byte hex 118 | # string to an integer directly using python radix conversion. 119 | # Basically, int(addr, 16) EQUALS: 120 | # aOrig = addr 121 | # addr = codecs.decode(addr, "hex") 122 | # addr = struct.unpack(">L", addr) 123 | # assert(addr == (int(aOrig, 16),)) 124 | addr = int(addr, 16) 125 | 126 | # system native byte order, 4-byte integer 127 | addr = struct.pack("=L", addr) 128 | addr = socket.inet_ntop(socket.AF_INET, addr) 129 | return addr 130 | 131 | isUDP = name == "udp" 132 | 133 | # Iterate four files: /proc/net/{tcp,udp}{,6} 134 | # ipv4 has no prefix, while ipv6 has 6 appended. 135 | for ver in ["", "6"]: 136 | with open(f"/proc/net/{name}{ver}", 'r') as proto: 137 | proto = proto.read().splitlines() 138 | proto = proto[1:] # drop header row 139 | 140 | for cxn in proto: 141 | cxn = cxn.split() 142 | 143 | # /proc/net/udp{,6} uses different constants for LISTENING 144 | if isUDP: 145 | # These constants are based on enum offsets inside 146 | # the Linux kernel itself. They aren't likely to ever 147 | # change since they are hardcoded in utilities. 148 | isListening = cxn[3] == "07" 149 | else: 150 | isListening = cxn[3] == "0A" 151 | 152 | # Right now this is a single-purpose tool so if inode is 153 | # not listening, we avoid further processing of this row. 154 | if not isListening: 155 | continue 156 | 157 | ip, port = cxn[1].split(':') 158 | if ver: 159 | ip = ipv6(ip) 160 | else: 161 | ip = ipv4(ip) 162 | 163 | port = int(port, 16) 164 | inode = cxn[9] 165 | 166 | # We just use a list here because creating a new sub-dict 167 | # for each entry was noticably slower than list indexing. 168 | inodes[int(inode)] = [ip, port, f"{name}{ver}"] 169 | 170 | processProc("tcp") 171 | processProc("udp") 172 | 173 | 174 | def generateInodePidMapFromProcGlob(): 175 | """ Loop over every fd in every process in /proc. 176 | 177 | The only way to map an fd back to a process is by looking 178 | at *every* processes fd and extracting backing inodes. 179 | 180 | It's basically like a big awkward database join where you don't 181 | have an index on the field you want. 182 | 183 | Also, due to Linux permissions (and Linux security concerns), 184 | only the root user can read fd listing of processes not owned 185 | by the current user. """ 186 | 187 | def appendToInodePidMap(fd, inodePidMap): 188 | """ Take a full path to /proc/[pid]/fd/[fd] for reading. 189 | 190 | Populates both pid and full command line of pid owning an inode we 191 | are interested in. 192 | 193 | Basically finds if any inodes on this pid is a listener we previously 194 | recorded into our ``inodes`` dict. """ 195 | _, _, pid, _, _ = fd.split('/') 196 | try: 197 | target = os.readlink(fd) 198 | except FileNotFoundError: 199 | # file vanished, can't do anything else 200 | return 201 | 202 | if target.startswith("socket"): 203 | ostype, inode = target.split(':') 204 | # strip brackets from fd string (it looks like: [fd]) 205 | inode = int(inode[1:-1]) 206 | inodePidMap[inode].append(int(pid)) 207 | 208 | # glob glob glob it all 209 | allFDs = glob.iglob("/proc/*/fd/*") 210 | 211 | inodePidMap = collections.defaultdict(list) 212 | for fd in allFDs: 213 | appendToInodePidMap(fd, inodePidMap) 214 | 215 | return inodePidMap 216 | 217 | 218 | def generateInodePidMapFromProcCustom(): 219 | """ Read /proc/pid_inode_map to populate inodePidMap """ 220 | 221 | inodePidMap = collections.defaultdict(list) 222 | with open("/proc/pid_inode_map", 'r') as pim: 223 | for line in pim: 224 | parts = line.split() 225 | pid = parts[0] 226 | name = parts[1] # unused, we lookup the full cmdline later 227 | pimInodes = set(parts[2:]) 228 | for inode in pimInodes: 229 | inodePidMap[int(inode)].append(int(pid)) 230 | 231 | return inodePidMap 232 | 233 | 234 | def addProcessNamesToInodes(inodePidMap): 235 | for inode in inodes: 236 | if inode in inodePidMap: 237 | for pid in inodePidMap[inode]: 238 | try: 239 | with open(f"/proc/{pid}/cmdline", 'r') as cmd: 240 | # /proc command line arguments are delimited by 241 | # null bytes, so undo that here... 242 | cmdline = cmd.read().split('\0') 243 | inodes[inode].append((pid, cmdline)) 244 | except BaseException: 245 | # files can vanish on us at any time (and that's okay!) 246 | # But, since the file is gone, we want the entire fd 247 | # entry gone too: 248 | pass # del inodes[inode] 249 | 250 | 251 | def checkListeners(): 252 | terminalWidth = run(TERMINAL_WIDTH) 253 | 254 | # Parse the four proc files: /proc/net/{tcp,udp}{,6} 255 | # populates the 'inodes' dict 256 | iterateProcNetworkingGatherListeningInodes() 257 | 258 | if os.path.isfile("/proc/pid_inode_map"): 259 | inodePidMap = generateInodePidMapFromProcCustom() 260 | else: 261 | inodePidMap = generateInodePidMapFromProcGlob() 262 | 263 | addProcessNamesToInodes(inodePidMap) 264 | 265 | try: 266 | cols = readOutput(terminalWidth)[0] 267 | cols = int(cols) 268 | except BaseException: 269 | cols = 80 270 | 271 | # Print our own custom output header... 272 | proto = "Proto" 273 | addr = "Listening" 274 | pid = "PID" 275 | process = "Process" 276 | print(f"{COLOR_HEADER}{proto:^5} {addr:^25} {pid:>5} {process:^30}") 277 | 278 | # Could sort by anything: ip, port, proto, pid, command name 279 | # (or even the inode integer if that provided any insight whatsoever) 280 | def compareByPidOrPort(what): 281 | k, v = what 282 | # v = [ip, port, proto, pid, cmd] 283 | # - OR - 284 | # v = [ip, port, proto] 285 | 286 | # If we're not running as root we can't pid and command mappings for 287 | # the processes of other users, so sort the pids we did find at end 288 | # of list and show UNKNOWN entries first 289 | # (because the lines will be shorter most likely so the bigger visual 290 | # weight should be lower in the display table) 291 | try: 292 | # Pid available! Sort by first pid, subsort by IP then port. 293 | return (1, v[3], v[0], v[1]) 294 | except BaseException: 295 | # No pid available! Sort by port number then IP then... port again. 296 | return (0, v[1], v[0], v[1]) 297 | 298 | # Sort results by pid... 299 | for name, vals in sorted(inodes.items(), key=compareByPidOrPort): 300 | attachedPids = vals[3:] 301 | if attachedPids: 302 | desc = [f"{pid:5} {' '.join(cmd)}" for pid, cmd in attachedPids] 303 | else: 304 | # If not running as root, we won't have pid or process, so use 305 | # defaults 306 | desc = ["UNKNOWN (must be root for global pid mappings)"] 307 | 308 | port = vals[1] 309 | try: 310 | # Convert port integer to service name if possible 311 | port = socket.getservbyport(port) 312 | except BaseException: 313 | # If no match, just use port number directly. 314 | pass 315 | 316 | addr = f"{vals[0]}:{port}" 317 | proto = vals[2] 318 | 319 | # If IP address looks like it could be visible to the world, 320 | # throw up a color. 321 | # Note: due to port forwarding and NAT and other issues, 322 | # this clearly isn't exhaustive. 323 | if re.match(likelyLocalOnly, addr): 324 | colorNotice = COLOR_OKAY 325 | else: 326 | colorNotice = COLOR_WARNING 327 | 328 | isFirstLine = True 329 | for line in desc: 330 | if isFirstLine: 331 | output = f"{colorNotice}{proto:5} {addr:25} {line}" 332 | isFirstLine = False 333 | else: 334 | output = f"{' ':31} {line}" 335 | 336 | # Be a polite terminal citizen by limiting our width to user's width 337 | # (colors take up non-visible space, so add it to our col count, 338 | # but only if it's a first line where we wrote the colors, 339 | # otherwise remain limited to actual line length) 340 | print(output[:cols + (len(colorNotice) if isFirstLine else 0)]) 341 | 342 | if ONLY_LOWEST_PID: 343 | break 344 | 345 | print(COLOR_END) 346 | 347 | 348 | if __name__ == "__main__": 349 | # cheap hack garbage way of setting one option. 350 | # if we need more options, obviously pull in argparse. 351 | if len(sys.argv) > 1: 352 | # When true, only print the first pid listening on 353 | # an IP:Port even if multiple child (or other) processes are attached. 354 | ONLY_LOWEST_PID = True 355 | else: 356 | ONLY_LOWEST_PID = False 357 | 358 | checkListeners() 359 | -------------------------------------------------------------------------------- /pid_inode_map/Makefile: -------------------------------------------------------------------------------- 1 | obj-m += pid_inode_map.o 2 | 3 | KERNELDIR ?= /lib/modules/$(shell uname -r)/build 4 | PWD := $(shell pwd) 5 | 6 | all: 7 | $(MAKE) -C $(KERNELDIR) M=$(PWD) 8 | clean: 9 | $(MAKE) -C $(KERNELDIR) M=$(PWD) clean 10 | -------------------------------------------------------------------------------- /pid_inode_map/pid_inode_map.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | /* Works with a 2018-era 4.15.0 kernel. ymmv going back in time. */ 9 | 10 | static const char *procFilename = "pid_inode_map"; 11 | 12 | static void iterate_inodes(struct seq_file *m, struct task_struct *task) { 13 | struct files_struct *files; 14 | struct fdtable *fdt; 15 | uint32_t i; 16 | 17 | seq_printf(m, "%d %s ", task->pid, task->comm); 18 | 19 | files = task->files; 20 | if (!files) { 21 | return; 22 | } 23 | 24 | rcu_read_lock(); 25 | fdt = files_fdtable(files); 26 | 27 | for (i = 0; i < fdt->max_fds; i++) { 28 | const struct file *file; 29 | 30 | file = fdt->fd[i]; 31 | if (file) { 32 | seq_printf(m, "%zu ", file->f_inode->i_ino); 33 | } 34 | } 35 | 36 | rcu_read_unlock(); 37 | seq_printf(m, "\n"); 38 | } 39 | 40 | static int generate_mapping(struct seq_file *m, void *data) { 41 | struct task_struct *task; 42 | 43 | for_each_process(task) { 44 | iterate_inodes(m, task); 45 | } 46 | 47 | return 0; 48 | } 49 | 50 | static int pid_inode_map_open(struct inode *inode, struct file *file) { 51 | return single_open(file, generate_mapping, NULL); 52 | } 53 | 54 | static const struct file_operations ops = {.owner = THIS_MODULE, 55 | .open = pid_inode_map_open, 56 | .read = seq_read, 57 | .llseek = seq_lseek, 58 | .release = single_release}; 59 | 60 | int __init pid_inode_map_init(void) { 61 | proc_create(procFilename, 0, NULL, &ops); 62 | return 0; 63 | } 64 | 65 | void __exit pid_inode_map_exit(void) { 66 | remove_proc_entry(procFilename, NULL); 67 | } 68 | 69 | MODULE_LICENSE("Dual MIT/GPL"); 70 | MODULE_AUTHOR("Matt Stancliff "); 71 | MODULE_DESCRIPTION( 72 | "Creates /proc/pid_inode_map showing all inodes a pid has open."); 73 | module_init(pid_inode_map_init); 74 | module_exit(pid_inode_map_exit); 75 | --------------------------------------------------------------------------------