From 8a99cd85a28b545a0392d2f3614afaa1d4162f48 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 01:57:21 +0000 Subject: [PATCH 1/4] Add lightweight macOS port scanner tool - Zero-dependency Python 3 implementation using stdlib only - Multi-threaded TCP scanning with configurable thread count - Supports port ranges, comma-separated lists, and top-100 preset - Service identification for 80+ common ports - Optional banner grabbing for service fingerprinting - Color terminal output with scan summary https://claude.ai/code/session_01DGJrP8B7ohyiEG1h1khr4f --- pentest/port-scanner/README.md | 56 ++++++++ pentest/port-scanner/portscan.py | 229 +++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 pentest/port-scanner/README.md create mode 100644 pentest/port-scanner/portscan.py diff --git a/pentest/port-scanner/README.md b/pentest/port-scanner/README.md new file mode 100644 index 0000000..a0a9fa6 --- /dev/null +++ b/pentest/port-scanner/README.md @@ -0,0 +1,56 @@ +# PortScan - 轻量级 macOS 端口扫描工具 + +纯 Python 标准库实现,无需安装任何依赖,专为 macOS 优化。 + +## 特性 + +- 零依赖,只用 Python 3 标准库 +- 多线程并发扫描,速度快 +- 支持自定义端口范围 / 列表 / Top 100 常用端口 +- Banner 抓取(可选) +- 服务识别(80+ 常见端口) +- 彩色终端输出 + +## 快速开始 + +```bash +# 克隆或进入目录 +cd pentest/port-scanner + +# 赋予执行权限(可选) +chmod +x portscan.py + +# 扫描 Top 100 常用端口(默认) +python3 portscan.py 192.168.1.1 + +# 扫描指定端口 +python3 portscan.py example.com -p 80,443,8080 + +# 扫描端口范围 +python3 portscan.py 10.0.0.1 -p 1-1024 + +# 开启 Banner 抓取 +python3 portscan.py 192.168.1.1 -p top100 -b + +# 全端口扫描(500线程,超时0.5s) +python3 portscan.py 192.168.1.1 -p 1-65535 -t 500 --timeout 0.5 +``` + +## 参数说明 + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `target` | 目标 IP 或域名 | 必填 | +| `-p / --ports` | 端口:`80`、`80,443`、`1-1024`、`top100` | `top100` | +| `-t / --threads` | 线程数(1-1000) | `100` | +| `--timeout` | 连接超时(秒) | `1.0` | +| `-b / --banner` | 开启 Banner 抓取 | 关闭 | + +## 内置 Top 100 端口 + +包含 FTP、SSH、HTTP/S、MySQL、Redis、MongoDB、Elasticsearch 等 100 个最常见服务端口。 + +## 注意事项 + +> 本工具仅供授权测试、安全研究和学习使用。 +> 请勿对未授权目标进行扫描,遵守当地法律法规。 diff --git a/pentest/port-scanner/portscan.py b/pentest/port-scanner/portscan.py new file mode 100644 index 0000000..56dce3a --- /dev/null +++ b/pentest/port-scanner/portscan.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 +""" +PortScan - Lightweight macOS Port Scanner +Usage: python3 portscan.py [options] +""" + +import socket +import sys +import argparse +import threading +import time +from queue import Queue +from datetime import datetime + + +# Common services mapping +COMMON_SERVICES = { + 21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 53: "DNS", + 80: "HTTP", 110: "POP3", 111: "RPC", 135: "MSRPC", 139: "NetBIOS", + 143: "IMAP", 443: "HTTPS", 445: "SMB", 465: "SMTPS", 587: "SMTP-TLS", + 993: "IMAPS", 995: "POP3S", 1080: "SOCKS", 1433: "MSSQL", 1521: "Oracle", + 2049: "NFS", 2181: "ZooKeeper", 3306: "MySQL", 3389: "RDP", 3690: "SVN", + 4444: "Metasploit", 5432: "PostgreSQL", 5900: "VNC", 5984: "CouchDB", + 6379: "Redis", 6443: "K8s-API", 7000: "Cassandra", 8080: "HTTP-Alt", + 8443: "HTTPS-Alt", 8888: "Jupyter", 9200: "Elasticsearch", 9300: "ES-Cluster", + 27017: "MongoDB", 27018: "MongoDB-shard", 28017: "MongoDB-HTTP", +} + +# Top 100 common ports +TOP_100_PORTS = [ + 21, 22, 23, 25, 53, 80, 110, 111, 135, 139, + 143, 443, 445, 465, 587, 993, 995, 1080, 1433, 1521, + 2049, 2181, 3306, 3389, 3690, 4444, 5432, 5900, 5984, 6379, + 6443, 7000, 8080, 8443, 8888, 9200, 9300, 27017, 27018, 28017, + 20, 69, 79, 81, 82, 88, 102, 119, 194, 389, + 500, 512, 513, 514, 515, 523, 548, 554, 631, 636, + 873, 902, 992, 1194, 1234, 1337, 1723, 2000, 2222, 2375, + 2376, 2888, 3000, 3001, 4000, 4001, 4040, 4848, 5000, 5001, + 5601, 6000, 6001, 6080, 6588, 7001, 7002, 7070, 7443, 7474, + 8000, 8008, 8009, 8081, 8161, 8181, 9000, 9001, 9090, 9999, +] + + +RESET = "\033[0m" +GREEN = "\033[92m" +RED = "\033[91m" +YELLOW = "\033[93m" +CYAN = "\033[96m" +BOLD = "\033[1m" +DIM = "\033[2m" + + +def print_banner(): + banner = f"""{CYAN}{BOLD} + ____ _ ____ + | _ \\ ___ _ __| |_/ ___| ___ __ _ _ __ + | |_) / _ \\| '__| __\\___ \\ / __/ _` | '_ \\ + | __/ (_) | | | |_ ___) | (_| (_| | | | | + |_| \\___/|_| \\__|____/ \\___\\__,_|_| |_| +{RESET}{DIM} Lightweight macOS Port Scanner{RESET} +""" + print(banner) + + +def resolve_target(target: str) -> str: + try: + ip = socket.gethostbyname(target) + if ip != target: + print(f"{DIM} Resolved {target} -> {ip}{RESET}") + return ip + except socket.gaierror: + print(f"{RED}[!] Cannot resolve host: {target}{RESET}") + sys.exit(1) + + +def grab_banner(ip: str, port: int, timeout: float) -> str: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(timeout) + s.connect((ip, port)) + # Send HTTP-style probe for common ports + if port in (80, 8080, 8000, 8888): + s.send(b"HEAD / HTTP/1.0\r\n\r\n") + elif port == 22: + pass # SSH sends banner automatically + else: + s.send(b"\r\n") + banner = s.recv(1024).decode("utf-8", errors="ignore").strip() + # Return only the first line + return banner.split("\n")[0][:80] if banner else "" + except Exception: + return "" + + +def scan_port(ip: str, port: int, timeout: float, grab: bool, results: list, lock: threading.Lock): + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(timeout) + result = s.connect_ex((ip, port)) + if result == 0: + service = COMMON_SERVICES.get(port, "unknown") + banner = "" + if grab: + banner = grab_banner(ip, port, timeout) + with lock: + results.append((port, service, banner)) + except Exception: + pass + + +def worker(ip: str, queue: Queue, timeout: float, grab: bool, results: list, lock: threading.Lock): + while not queue.empty(): + port = queue.get() + scan_port(ip, port, timeout, grab, results, lock) + queue.task_done() + + +def parse_ports(port_arg: str) -> list: + """Parse port argument: '80', '80,443', '1-1024', 'top100'""" + if port_arg == "top100": + return sorted(TOP_100_PORTS) + ports = [] + for part in port_arg.split(","): + part = part.strip() + if "-" in part: + start, end = part.split("-", 1) + ports.extend(range(int(start), int(end) + 1)) + else: + ports.append(int(part)) + return sorted(set(ports)) + + +def run_scan(target: str, ports: list, threads: int, timeout: float, grab: bool): + ip = resolve_target(target) + print(f"\n{BOLD} Target :{RESET} {ip}") + print(f"{BOLD} Ports :{RESET} {len(ports)} port(s)") + print(f"{BOLD} Threads :{RESET} {threads}") + print(f"{BOLD} Timeout :{RESET} {timeout}s") + print(f"{BOLD} Started :{RESET} {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"\n{DIM} Scanning...{RESET}\n") + + queue: Queue = Queue() + results: list = [] + lock = threading.Lock() + + for port in ports: + queue.put(port) + + start_time = time.time() + thread_list = [] + for _ in range(min(threads, len(ports))): + t = threading.Thread(target=worker, args=(ip, queue, timeout, grab, results, lock)) + t.daemon = True + t.start() + thread_list.append(t) + + queue.join() + elapsed = time.time() - start_time + + # Sort results by port + results.sort(key=lambda x: x[0]) + + print(f" {'PORT':<8} {'STATE':<10} {'SERVICE':<16} {'BANNER'}") + print(f" {'-'*8} {'-'*10} {'-'*16} {'-'*40}") + + if results: + for port, service, banner in results: + banner_str = f"{DIM}{banner}{RESET}" if banner else "" + print(f" {GREEN}{port:<8}{RESET} {'open':<10} {YELLOW}{service:<16}{RESET} {banner_str}") + else: + print(f" {DIM}No open ports found.{RESET}") + + print(f"\n {DIM}Scanned {len(ports)} port(s) in {elapsed:.2f}s | {len(results)} open{RESET}\n") + + +def main(): + print_banner() + + parser = argparse.ArgumentParser( + description="Lightweight macOS Port Scanner", + formatter_class=argparse.RawTextHelpFormatter, + epilog=""" +Examples: + python3 portscan.py 192.168.1.1 + python3 portscan.py example.com -p 80,443,8080 + python3 portscan.py 10.0.0.1 -p 1-1024 -t 200 + python3 portscan.py 192.168.1.1 -p top100 -b + python3 portscan.py scanme.nmap.org -p 1-65535 -t 500 --timeout 0.5 + """ + ) + parser.add_argument("target", help="Target IP address or hostname") + parser.add_argument( + "-p", "--ports", + default="top100", + help="Ports to scan: '80', '80,443', '1-1024', 'top100' (default: top100)" + ) + parser.add_argument( + "-t", "--threads", + type=int, default=100, + help="Number of threads (default: 100)" + ) + parser.add_argument( + "--timeout", + type=float, default=1.0, + help="Connection timeout in seconds (default: 1.0)" + ) + parser.add_argument( + "-b", "--banner", + action="store_true", + help="Enable banner grabbing" + ) + + args = parser.parse_args() + + try: + ports = parse_ports(args.ports) + except ValueError as e: + print(f"{RED}[!] Invalid port specification: {e}{RESET}") + sys.exit(1) + + if not 1 <= args.threads <= 1000: + print(f"{RED}[!] Threads must be between 1 and 1000{RESET}") + sys.exit(1) + + run_scan(args.target, ports, args.threads, args.timeout, args.banner) + + +if __name__ == "__main__": + main() From 75f7fca04724b256694ff525902b07d174787d03 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 03:11:18 +0000 Subject: [PATCH 2/4] fix: add connection semaphore to prevent SIGKILL on macOS Limit concurrent socket connections to 50 via threading.Semaphore to prevent macOS from killing the process (Killed: 9) when too many file descriptors are opened simultaneously during a scan. https://claude.ai/code/session_01DGJrP8B7ohyiEG1h1khr4f --- pentest/port-scanner/portscan.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/pentest/port-scanner/portscan.py b/pentest/port-scanner/portscan.py index 56dce3a..21b9ce0 100644 --- a/pentest/port-scanner/portscan.py +++ b/pentest/port-scanner/portscan.py @@ -12,6 +12,9 @@ from queue import Queue from datetime import datetime +# Limit concurrent socket connections to avoid SIGKILL on macOS +_connection_sem = threading.Semaphore(50) + # Common services mapping COMMON_SERVICES = { @@ -93,19 +96,20 @@ def grab_banner(ip: str, port: int, timeout: float) -> str: def scan_port(ip: str, port: int, timeout: float, grab: bool, results: list, lock: threading.Lock): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.settimeout(timeout) - result = s.connect_ex((ip, port)) - if result == 0: - service = COMMON_SERVICES.get(port, "unknown") - banner = "" - if grab: - banner = grab_banner(ip, port, timeout) - with lock: - results.append((port, service, banner)) - except Exception: - pass + with _connection_sem: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.settimeout(timeout) + result = s.connect_ex((ip, port)) + if result == 0: + service = COMMON_SERVICES.get(port, "unknown") + banner = "" + if grab: + banner = grab_banner(ip, port, timeout) + with lock: + results.append((port, service, banner)) + except Exception: + pass def worker(ip: str, queue: Queue, timeout: float, grab: bool, results: list, lock: threading.Lock): From 8ac9e347c9c12dc043adf4a4ad48a9082f4132e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Mar 2026 03:21:56 +0000 Subject: [PATCH 3/4] fix: reduce concurrency limits to prevent SIGKILL on macOS Lower semaphore from 50 to 30 and default thread count from 100 to 50 to stay well below macOS's default fd limit of 256 per process. https://claude.ai/code/session_01DGJrP8B7ohyiEG1h1khr4f --- pentest/port-scanner/portscan.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pentest/port-scanner/portscan.py b/pentest/port-scanner/portscan.py index 21b9ce0..57e5a59 100644 --- a/pentest/port-scanner/portscan.py +++ b/pentest/port-scanner/portscan.py @@ -13,7 +13,8 @@ from datetime import datetime # Limit concurrent socket connections to avoid SIGKILL on macOS -_connection_sem = threading.Semaphore(50) +# macOS default fd limit is 256; keep well below that +_connection_sem = threading.Semaphore(30) # Common services mapping @@ -200,8 +201,8 @@ def main(): ) parser.add_argument( "-t", "--threads", - type=int, default=100, - help="Number of threads (default: 100)" + type=int, default=50, + help="Number of threads (default: 50)" ) parser.add_argument( "--timeout", From 7a94bac1ffb15f778d039b7ba5562019004fd00f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 02:46:43 +0000 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20permanently=20resolve=20Killed:9=20?= =?UTF-8?q?=E2=80=94=20close=20scan=20socket=20before=20banner,=20fix=20wo?= =?UTF-8?q?rker=20race=20condition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes fixed: 1. scan socket was still open while grab_banner() opened a second socket, doubling actual concurrent FDs; moved grab_banner() call after the `with socket` block so the scan socket is always closed first 2. worker() used `while not queue.empty()` + `queue.get()` — a TOCTOU race where threads could block indefinitely on get(); replaced with queue.get(block=False) + Empty exception 3. semaphore was a fixed global (30) independent of thread count; now created dynamically in run_scan() as min(threads, 20) so the limit always matches actual concurrency; default threads lowered 50→20 4. added _raise_fd_limit() to programmatically raise macOS fd soft limit to 2048 at startup https://claude.ai/code/session_01DGJrP8B7ohyiEG1h1khr4f --- pentest/port-scanner/portscan.py | 48 ++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/pentest/port-scanner/portscan.py b/pentest/port-scanner/portscan.py index 57e5a59..4f51431 100644 --- a/pentest/port-scanner/portscan.py +++ b/pentest/port-scanner/portscan.py @@ -9,12 +9,23 @@ import argparse import threading import time -from queue import Queue +import resource +from queue import Queue, Empty from datetime import datetime -# Limit concurrent socket connections to avoid SIGKILL on macOS -# macOS default fd limit is 256; keep well below that -_connection_sem = threading.Semaphore(30) +# Raised at startup; semaphore is created per-scan based on thread count +_connection_sem: threading.Semaphore = threading.Semaphore(10) + + +def _raise_fd_limit(): + """Raise the process file-descriptor soft limit to safe headroom on macOS.""" + try: + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + target = min(hard, 2048) + if soft < target: + resource.setrlimit(resource.RLIMIT_NOFILE, (target, hard)) + except Exception: + pass # Common services mapping @@ -102,20 +113,22 @@ def scan_port(ip: str, port: int, timeout: float, grab: bool, results: list, loc with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.settimeout(timeout) result = s.connect_ex((ip, port)) - if result == 0: - service = COMMON_SERVICES.get(port, "unknown") - banner = "" - if grab: - banner = grab_banner(ip, port, timeout) - with lock: - results.append((port, service, banner)) + # scan socket is now CLOSED before we open banner socket + if result == 0: + service = COMMON_SERVICES.get(port, "unknown") + banner = grab_banner(ip, port, timeout) if grab else "" + with lock: + results.append((port, service, banner)) except Exception: pass def worker(ip: str, queue: Queue, timeout: float, grab: bool, results: list, lock: threading.Lock): - while not queue.empty(): - port = queue.get() + while True: + try: + port = queue.get(block=False) + except Empty: + break scan_port(ip, port, timeout, grab, results, lock) queue.task_done() @@ -136,6 +149,11 @@ def parse_ports(port_arg: str) -> list: def run_scan(target: str, ports: list, threads: int, timeout: float, grab: bool): + global _connection_sem + _raise_fd_limit() + # Keep concurrent sockets well below system fd limit. + # With banner grabbing each slot uses 1 socket at a time (scan closes before banner opens). + _connection_sem = threading.Semaphore(min(threads, 20)) ip = resolve_target(target) print(f"\n{BOLD} Target :{RESET} {ip}") print(f"{BOLD} Ports :{RESET} {len(ports)} port(s)") @@ -201,8 +219,8 @@ def main(): ) parser.add_argument( "-t", "--threads", - type=int, default=50, - help="Number of threads (default: 50)" + type=int, default=20, + help="Number of threads (default: 20)" ) parser.add_argument( "--timeout",