#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
scan445.py —— 慢速 + 抖动 + 随机顺序 的 SMB(445) 探测脚本

用途：经已建立的 SOCKS5 隧道 (127.0.0.1:1080 -> 目标侧 t.aspx HTTP relay)
      对内网 C 段做低噪声资产发现，为横向移动挑选可用 SMB 目标。

用法：
  python3 scan445.py --cidr 10.10.10.0/24 --port 445 --workers 4 \
      --delay 1.2 2.6 --timeout 9 --out scan445_open.txt --log scan445_full.log

OPSEC 设计：
  * 主机顺序随机洗牌，避免"顺序扫段"特征
  * 每台主机探测前随机等待 --delay 秒（默认 1.2~2.6s），并发受 --workers 限制
  * 连上 TCP 后只发一个 SMB2 NEGOTIATE 请求，读 DialectRevision / SecurityMode：
      - 确认真是 SMB 服务（而非其它服务占 445）
      - 拿 SMB 版本（2.0.2/2.1/3.0/3.0.2/3.1.1）
      - 看是否强制签名（signing=required 则不可 NTLM 中继）
  * 分类：OPEN / CLOSED(RST 拒绝) / FILTERED(超时不可达) / ERR
  * 只有 OPEN 写结果文件；全量明细写日志文件

结果文件格式（每行）：
  <ip>:<port>\t<dialect>\tsigning=<required|enabled|off>\t<rtt>
"""
import argparse
import datetime
import ipaddress
import os
import queue
import random
import socket
import struct
import sys
import threading
import time

PROXY = ("127.0.0.1", 1080)

DIALECTS = {0x0202: "SMB2.0.2", 0x0210: "SMB2.1", 0x0300: "SMB3.0",
            0x0302: "SMB3.0.2", 0x0311: "SMB3.1.1"}

_lock = threading.Lock()
_stat = {"open": 0, "closed": 0, "filtered": 0, "err": 0, "done": 0}
_fh_result = None
_fh_log = None


def emit(line, important=True):
    """打印 + 写日志文件；important 行为实时打印（OPEN/ERR 等）"""
    ts = datetime.datetime.now().strftime("%H:%M:%S")
    full = "[%s] %s" % (ts, line)
    with _lock:
        if _fh_log:
            _fh_log.write(full + "\n")
            _fh_log.flush()
        if important:
            print(full, flush=True)


def recvn(s, n):
    buf = b""
    while len(buf) < n:
        c = s.recv(n - len(buf))
        if not c:
            raise IOError("closed")
        buf += c
    return buf


def socks_connect(host, port, timeout):
    """通过本地 SOCKS5 代理建立到 host:port 的 TCP 连接（成功返回裸 socket）"""
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(timeout)
    s.connect(PROXY)
    s.sendall(b"\x05\x01\x00")
    g = recvn(s, 2)
    if g != b"\x05\x00":
        raise IOError("socks greet fail %r" % g)
    try:
        addr, atyp = socket.inet_aton(host), 1
    except OSError:
        addr, atyp = bytes([len(host)]) + host.encode(), 3
    s.sendall(b"\x05\x01\x00" + bytes([atyp]) + addr + struct.pack("!H", port))
    rep = recvn(s, 10)
    if rep[1] != 0:
        raise IOError("socks rep=%d" % rep[1])   # 4=host unreachable 5=refused
    return s


def smb2_negotiate(s):
    """发一个 SMB2 NEGOTIATE，返回 (dialect_str, signing_str) 或 None"""
    dialects = [0x0202, 0x0210, 0x0300, 0x0302, 0x0311]
    body = struct.pack("<HHHH", 36, len(dialects), 1, 0)          # StructSize,DialectCount,SecMode,Reserved
    body += struct.pack("<I", 0)                                  # Capabilities
    body += os.urandom(16)                                        # ClientGuid
    body += struct.pack("<Q", 0)                                  # ClientStartTime
    for d in dialects:
        body += struct.pack("<H", d)
    hdr = b"\xfeSMB" + struct.pack("<H", 64)                      # ProtocolId + StructureSize
    hdr += struct.pack("<I", 0)                                   # CreditCharge
    hdr += struct.pack("<I", 0)                                   # Status
    hdr += struct.pack("<H", 0)                                   # Command = NEGOTIATE
    hdr += struct.pack("<H", 1)                                   # CreditRequest
    hdr += struct.pack("<I", 0)                                   # Flags
    hdr += struct.pack("<I", 0)                                   # NextCommand
    hdr += struct.pack("<Q", 0)                                   # MessageId
    hdr += struct.pack("<I", 0)                                   # Reserved
    hdr += struct.pack("<I", 0)                                   # TreeId
    hdr += struct.pack("<Q", 0)                                   # SessionId
    hdr += b"\x00" * 16                                           # Signature
    pkt = hdr + body
    s.sendall(b"\x00" + len(pkt).to_bytes(3, "big") + pkt)

    d = b""
    t0 = time.time()
    while len(d) < 4 and time.time() - t0 < 8:
        try:
            c = s.recv(4096)
        except socket.timeout:
            break
        if not c:
            break
        d += c
    if len(d) < 4:
        return None
    need = int.from_bytes(d[1:4], "big") + 4
    t0 = time.time()
    while len(d) < need and time.time() - t0 < 8:
        try:
            c = s.recv(need - len(d))
        except socket.timeout:
            break
        if not c:
            break
        d += c
    if len(d) < 74:
        return None
    secmode = int.from_bytes(d[70:72], "little")
    dialect = int.from_bytes(d[72:74], "little")
    sign = "required" if secmode & 0x02 else ("enabled" if secmode & 0x01 else "off")
    return DIALECTS.get(dialect, "0x%04x" % dialect), sign


def worker(wq, args):
    while True:
        try:
            ip = wq.get_nowait()
        except queue.Empty:
            return
        # 抖动：每台主机之间随机等待，抹平扫描节律
        time.sleep(random.uniform(args.delay[0], args.delay[1]))
        t0 = time.time()
        try:
            s = socks_connect(ip, args.port, args.timeout)
            rtt = time.time() - t0
            info = None
            try:
                info = smb2_negotiate(s)
            except Exception as ex:
                emit("WARN  %-15s :%d SMB 探测异常 %s" % (ip, args.port, ex))
            try:
                s.close()
            except Exception:
                pass
            with _lock:
                _stat["open"] += 1
                _stat["done"] += 1
                if _fh_result:
                    if info:
                        _fh_result.write("%s:%d\t%s\tsigning=%s\t%.1fs\n"
                                         % (ip, args.port, info[0], info[1], rtt))
                    else:
                        _fh_result.write("%s:%d\tTCPOPEN_NO_SMB\t-\t%.1fs\n"
                                         % (ip, args.port, rtt))
                    _fh_result.flush()
            if info:
                emit("OPEN      %-15s :%-5d %-8s signing=%-8s %.1fs"
                     % (ip, args.port, info[0], info[1], rtt))
            else:
                emit("OPEN      %-15s :%-5d (TCP 通，SMB 无响应)      %.1fs"
                     % (ip, args.port, rtt))
        except IOError as ex:
            m = str(ex)
            with _lock:
                _stat["done"] += 1
                if "rep=4" in m:
                    _stat["filtered"] += 1
                    kind = "FILTERED"
                elif "rep=5" in m:
                    _stat["closed"] += 1
                    kind = "CLOSED"
                else:
                    _stat["err"] += 1
                    kind = "ERR"
            if kind == "ERR":
                emit("ERR       %-15s :%-5d %s" % (ip, args.port, m))
            else:
                emit("%-9s %-15s :%-5d" % (kind, ip, args.port), important=False)
        except Exception as ex:
            with _lock:
                _stat["err"] += 1
                _stat["done"] += 1
            emit("ERR       %-15s :%-5d %s" % (ip, args.port, ex))
        finally:
            wq.task_done()


def main():
    global _fh_result, _fh_log
    p = argparse.ArgumentParser()
    p.add_argument("--cidr", default="10.10.10.0/24")
    p.add_argument("--port", type=int, default=445)
    p.add_argument("--workers", type=int, default=4)
    p.add_argument("--delay", type=float, nargs=2, default=[1.2, 2.6],
                   metavar=("MIN", "MAX"))
    p.add_argument("--timeout", type=float, default=9.0)
    p.add_argument("--exclude", default="", help="逗号分隔的排除 IP")
    p.add_argument("--out", default="scan445_open.txt")
    p.add_argument("--log", default="scan445_full.log")
    p.add_argument("--shuffle-seed", type=int, default=None)
    args = p.parse_args()

    excl = set(x.strip() for x in args.exclude.split(",") if x.strip())
    hosts = [str(h) for h in ipaddress.ip_network(args.cidr, strict=False).hosts()]
    hosts = [h for h in hosts if h not in excl]
    random.seed(args.shuffle_seed)
    random.shuffle(hosts)

    _fh_result = open(args.out, "a", buffering=1)
    _fh_log = open(args.log, "a", buffering=1)
    emit("=== scan445 启动: cidr=%s port=%d hosts=%d workers=%d delay=%.1f~%.1f timeout=%.1fs ==="
         % (args.cidr, args.port, len(hosts), args.workers, args.delay[0], args.delay[1], args.timeout))
    emit("=== 顺序已随机化(seed=%s) 排除=%s ===" % (args.shuffle_seed, sorted(excl) or "无"))

    wq = queue.Queue()
    for h in hosts:
        wq.put(h)
    t0 = time.time()
    ths = [threading.Thread(target=worker, args=(wq, args), daemon=True)
           for _ in range(args.workers)]
    for th in ths:
        th.start()
    for th in ths:
        th.join()
    dt = time.time() - t0
    emit("=== 完成: 用时 %.0fs | OPEN=%d CLOSED=%d FILTERED=%d ERR=%d ==="
         % (dt, _stat["open"], _stat["closed"], _stat["filtered"], _stat["err"]))
    emit("=== 开放清单已写入 %s （全量日志 %s） ===" % (args.out, args.log))
    _fh_result.close()
    _fh_log.close()


if __name__ == "__main__":
    main()
