| ## This file is part of Scapy |
| ## See http://www.secdev.org/projects/scapy for more informations |
| ## Copyright (C) Philippe Biondi <phil@secdev.org> |
| ## This program is published under a GPLv2 license |
| |
| """ |
| Clone of p0f passive OS fingerprinting |
| """ |
| |
| from __future__ import absolute_import |
| from __future__ import print_function |
| import time |
| import struct |
| import os |
| import socket |
| import random |
| |
| from scapy.data import KnowledgeBase |
| from scapy.config import conf |
| from scapy.compat import raw |
| from scapy.layers.inet import IP, TCP, TCPOptions |
| from scapy.packet import NoPayload, Packet |
| from scapy.error import warning, Scapy_Exception, log_runtime |
| from scapy.volatile import RandInt, RandByte, RandChoice, RandNum, RandShort, RandString |
| from scapy.sendrecv import sniff |
| from scapy.modules import six |
| from scapy.modules.six.moves import map, range |
| if conf.route is None: |
| # unused import, only to initialize conf.route |
| import scapy.route |
| |
| conf.p0f_base ="/etc/p0f/p0f.fp" |
| conf.p0fa_base ="/etc/p0f/p0fa.fp" |
| conf.p0fr_base ="/etc/p0f/p0fr.fp" |
| conf.p0fo_base ="/etc/p0f/p0fo.fp" |
| |
| |
| ############### |
| ## p0f stuff ## |
| ############### |
| |
| # File format (according to p0f.fp) : |
| # |
| # wwww:ttt:D:ss:OOO...:QQ:OS:Details |
| # |
| # wwww - window size |
| # ttt - initial TTL |
| # D - don't fragment bit (0=unset, 1=set) |
| # ss - overall SYN packet size |
| # OOO - option value and order specification |
| # QQ - quirks list |
| # OS - OS genre |
| # details - OS description |
| |
| class p0fKnowledgeBase(KnowledgeBase): |
| def __init__(self, filename): |
| KnowledgeBase.__init__(self, filename) |
| #self.ttl_range=[255] |
| def lazy_init(self): |
| try: |
| f=open(self.filename) |
| except IOError: |
| warning("Can't open base %s", self.filename) |
| return |
| try: |
| self.base = [] |
| for l in f: |
| if l[0] in ["#","\n"]: |
| continue |
| l = tuple(l.split(":")) |
| if len(l) < 8: |
| continue |
| def a2i(x): |
| if x.isdigit(): |
| return int(x) |
| return x |
| li = [a2i(e) for e in l[1:4]] |
| #if li[0] not in self.ttl_range: |
| # self.ttl_range.append(li[0]) |
| # self.ttl_range.sort() |
| self.base.append((l[0], li[0], li[1], li[2], l[4], l[5], l[6], l[7][:-1])) |
| except: |
| warning("Can't parse p0f database (new p0f version ?)") |
| self.base = None |
| f.close() |
| |
| p0f_kdb, p0fa_kdb, p0fr_kdb, p0fo_kdb = None, None, None, None |
| |
| def p0f_load_knowledgebases(): |
| global p0f_kdb, p0fa_kdb, p0fr_kdb, p0fo_kdb |
| p0f_kdb = p0fKnowledgeBase(conf.p0f_base) |
| p0fa_kdb = p0fKnowledgeBase(conf.p0fa_base) |
| p0fr_kdb = p0fKnowledgeBase(conf.p0fr_base) |
| p0fo_kdb = p0fKnowledgeBase(conf.p0fo_base) |
| |
| p0f_load_knowledgebases() |
| |
| def p0f_selectdb(flags): |
| # tested flags: S, R, A |
| if flags & 0x16 == 0x2: |
| # SYN |
| return p0f_kdb |
| elif flags & 0x16 == 0x12: |
| # SYN/ACK |
| return p0fa_kdb |
| elif flags & 0x16 in [ 0x4, 0x14 ]: |
| # RST RST/ACK |
| return p0fr_kdb |
| elif flags & 0x16 == 0x10: |
| # ACK |
| return p0fo_kdb |
| else: |
| return None |
| |
| def packet2p0f(pkt): |
| pkt = pkt.copy() |
| pkt = pkt.__class__(raw(pkt)) |
| while pkt.haslayer(IP) and pkt.haslayer(TCP): |
| pkt = pkt.getlayer(IP) |
| if isinstance(pkt.payload, TCP): |
| break |
| pkt = pkt.payload |
| |
| if not isinstance(pkt, IP) or not isinstance(pkt.payload, TCP): |
| raise TypeError("Not a TCP/IP packet") |
| #if pkt.payload.flags & 0x7 != 0x02: #S,!F,!R |
| # raise TypeError("Not a SYN or SYN/ACK packet") |
| |
| db = p0f_selectdb(pkt.payload.flags) |
| |
| #t = p0f_kdb.ttl_range[:] |
| #t += [pkt.ttl] |
| #t.sort() |
| #ttl=t[t.index(pkt.ttl)+1] |
| ttl = pkt.ttl |
| |
| ss = len(pkt) |
| # from p0f/config.h : PACKET_BIG = 100 |
| if ss > 100: |
| if db == p0fr_kdb: |
| # p0fr.fp: "Packet size may be wildcarded. The meaning of |
| # wildcard is, however, hardcoded as 'size > |
| # PACKET_BIG'" |
| ss = '*' |
| else: |
| ss = 0 |
| if db == p0fo_kdb: |
| # p0fo.fp: "Packet size MUST be wildcarded." |
| ss = '*' |
| |
| ooo = "" |
| mss = -1 |
| qqT = False |
| qqP = False |
| #qqBroken = False |
| ilen = (pkt.payload.dataofs << 2) - 20 # from p0f.c |
| for option in pkt.payload.options: |
| ilen -= 1 |
| if option[0] == "MSS": |
| ooo += "M" + str(option[1]) + "," |
| mss = option[1] |
| # FIXME: qqBroken |
| ilen -= 3 |
| elif option[0] == "WScale": |
| ooo += "W" + str(option[1]) + "," |
| # FIXME: qqBroken |
| ilen -= 2 |
| elif option[0] == "Timestamp": |
| if option[1][0] == 0: |
| ooo += "T0," |
| else: |
| ooo += "T," |
| if option[1][1] != 0: |
| qqT = True |
| ilen -= 9 |
| elif option[0] == "SAckOK": |
| ooo += "S," |
| ilen -= 1 |
| elif option[0] == "NOP": |
| ooo += "N," |
| elif option[0] == "EOL": |
| ooo += "E," |
| if ilen > 0: |
| qqP = True |
| else: |
| if isinstance(option[0], str): |
| ooo += "?%i," % TCPOptions[1][option[0]] |
| else: |
| ooo += "?%i," % option[0] |
| # FIXME: ilen |
| ooo = ooo[:-1] |
| if ooo == "": ooo = "." |
| |
| win = pkt.payload.window |
| if mss != -1: |
| if mss != 0 and win % mss == 0: |
| win = "S" + str(win/mss) |
| elif win % (mss + 40) == 0: |
| win = "T" + str(win/(mss+40)) |
| win = str(win) |
| |
| qq = "" |
| |
| if db == p0fr_kdb: |
| if pkt.payload.flags & 0x10 == 0x10: |
| # p0fr.fp: "A new quirk, 'K', is introduced to denote |
| # RST+ACK packets" |
| qq += "K" |
| # The two next cases should also be only for p0f*r*, but although |
| # it's not documented (or I have not noticed), p0f seems to |
| # support the '0' and 'Q' quirks on any databases (or at the least |
| # "classical" p0f.fp). |
| if pkt.payload.seq == pkt.payload.ack: |
| # p0fr.fp: "A new quirk, 'Q', is used to denote SEQ number |
| # equal to ACK number." |
| qq += "Q" |
| if pkt.payload.seq == 0: |
| # p0fr.fp: "A new quirk, '0', is used to denote packets |
| # with SEQ number set to 0." |
| qq += "0" |
| if qqP: |
| qq += "P" |
| if pkt.id == 0: |
| qq += "Z" |
| if pkt.options != []: |
| qq += "I" |
| if pkt.payload.urgptr != 0: |
| qq += "U" |
| if pkt.payload.reserved != 0: |
| qq += "X" |
| if pkt.payload.ack != 0: |
| qq += "A" |
| if qqT: |
| qq += "T" |
| if db == p0fo_kdb: |
| if pkt.payload.flags & 0x20 != 0: |
| # U |
| # p0fo.fp: "PUSH flag is excluded from 'F' quirk checks" |
| qq += "F" |
| else: |
| if pkt.payload.flags & 0x28 != 0: |
| # U or P |
| qq += "F" |
| if db != p0fo_kdb and not isinstance(pkt.payload.payload, NoPayload): |
| # p0fo.fp: "'D' quirk is not checked for." |
| qq += "D" |
| # FIXME : "!" - broken options segment: not handled yet |
| |
| if qq == "": |
| qq = "." |
| |
| return (db, (win, ttl, pkt.flags.DF, ss, ooo, qq)) |
| |
| def p0f_correl(x,y): |
| d = 0 |
| # wwww can be "*" or "%nn". "Tnn" and "Snn" should work fine with |
| # the x[0] == y[0] test. |
| d += (x[0] == y[0] or y[0] == "*" or (y[0][0] == "%" and x[0].isdigit() and (int(x[0]) % int(y[0][1:])) == 0)) |
| # ttl |
| d += (y[1] >= x[1] and y[1] - x[1] < 32) |
| for i in [2, 5]: |
| d += (x[i] == y[i] or y[i] == '*') |
| # '*' has a special meaning for ss |
| d += x[3] == y[3] |
| xopt = x[4].split(",") |
| yopt = y[4].split(",") |
| if len(xopt) == len(yopt): |
| same = True |
| for i in range(len(xopt)): |
| if not (xopt[i] == yopt[i] or |
| (len(yopt[i]) == 2 and len(xopt[i]) > 1 and |
| yopt[i][1] == "*" and xopt[i][0] == yopt[i][0]) or |
| (len(yopt[i]) > 2 and len(xopt[i]) > 1 and |
| yopt[i][1] == "%" and xopt[i][0] == yopt[i][0] and |
| int(xopt[i][1:]) % int(yopt[i][2:]) == 0)): |
| same = False |
| break |
| if same: |
| d += len(xopt) |
| return d |
| |
| |
| @conf.commands.register |
| def p0f(pkt): |
| """Passive OS fingerprinting: which OS emitted this TCP packet ? |
| p0f(packet) -> accuracy, [list of guesses] |
| """ |
| db, sig = packet2p0f(pkt) |
| if db: |
| pb = db.get_base() |
| else: |
| pb = [] |
| if not pb: |
| warning("p0f base empty.") |
| return [] |
| #s = len(pb[0][0]) |
| r = [] |
| max = len(sig[4].split(",")) + 5 |
| for b in pb: |
| d = p0f_correl(sig,b) |
| if d == max: |
| r.append((b[6], b[7], b[1] - pkt[IP].ttl)) |
| return r |
| |
| def prnp0f(pkt): |
| """Calls p0f and returns a user-friendly output""" |
| # we should print which DB we use |
| try: |
| r = p0f(pkt) |
| except: |
| return |
| if r == []: |
| r = ("UNKNOWN", "[" + ":".join(map(str, packet2p0f(pkt)[1])) + ":?:?]", None) |
| else: |
| r = r[0] |
| uptime = None |
| try: |
| uptime = pkt2uptime(pkt) |
| except: |
| pass |
| if uptime == 0: |
| uptime = None |
| res = pkt.sprintf("%IP.src%:%TCP.sport% - " + r[0] + " " + r[1]) |
| if uptime is not None: |
| res += pkt.sprintf(" (up: " + str(uptime/3600) + " hrs)\n -> %IP.dst%:%TCP.dport% (%TCP.flags%)") |
| else: |
| res += pkt.sprintf("\n -> %IP.dst%:%TCP.dport% (%TCP.flags%)") |
| if r[2] is not None: |
| res += " (distance " + str(r[2]) + ")" |
| print(res) |
| |
| @conf.commands.register |
| def pkt2uptime(pkt, HZ=100): |
| """Calculate the date the machine which emitted the packet booted using TCP timestamp |
| pkt2uptime(pkt, [HZ=100])""" |
| if not isinstance(pkt, Packet): |
| raise TypeError("Not a TCP packet") |
| if isinstance(pkt,NoPayload): |
| raise TypeError("Not a TCP packet") |
| if not isinstance(pkt, TCP): |
| return pkt2uptime(pkt.payload) |
| for opt in pkt.options: |
| if opt[0] == "Timestamp": |
| #t = pkt.time - opt[1][0] * 1.0/HZ |
| #return time.ctime(t) |
| t = opt[1][0] / HZ |
| return t |
| raise TypeError("No timestamp option") |
| |
| def p0f_impersonate(pkt, osgenre=None, osdetails=None, signature=None, |
| extrahops=0, mtu=1500, uptime=None): |
| """Modifies pkt so that p0f will think it has been sent by a |
| specific OS. If osdetails is None, then we randomly pick up a |
| personality matching osgenre. If osgenre and signature are also None, |
| we use a local signature (using p0f_getlocalsigs). If signature is |
| specified (as a tuple), we use the signature. |
| |
| For now, only TCP Syn packets are supported. |
| Some specifications of the p0f.fp file are not (yet) implemented.""" |
| pkt = pkt.copy() |
| #pkt = pkt.__class__(raw(pkt)) |
| while pkt.haslayer(IP) and pkt.haslayer(TCP): |
| pkt = pkt.getlayer(IP) |
| if isinstance(pkt.payload, TCP): |
| break |
| pkt = pkt.payload |
| |
| if not isinstance(pkt, IP) or not isinstance(pkt.payload, TCP): |
| raise TypeError("Not a TCP/IP packet") |
| |
| db = p0f_selectdb(pkt.payload.flags) |
| if osgenre: |
| pb = db.get_base() |
| if pb is None: |
| pb = [] |
| pb = [x for x in pb if x[6] == osgenre] |
| if osdetails: |
| pb = [x for x in pb if x[7] == osdetails] |
| elif signature: |
| pb = [signature] |
| else: |
| pb = p0f_getlocalsigs()[db] |
| if db == p0fr_kdb: |
| # 'K' quirk <=> RST+ACK |
| if pkt.payload.flags & 0x4 == 0x4: |
| pb = [x for x in pb if 'K' in x[5]] |
| else: |
| pb = [x for x in pb if 'K' not in x[5]] |
| if not pb: |
| raise Scapy_Exception("No match in the p0f database") |
| pers = pb[random.randint(0, len(pb) - 1)] |
| |
| # options (we start with options because of MSS) |
| # Take the options already set as "hints" to use in the new packet if we |
| # can. MSS, WScale and Timestamp can all be wildcarded in a signature, so |
| # we'll use the already-set values if they're valid integers. |
| orig_opts = dict(pkt.payload.options) |
| int_only = lambda val: val if isinstance(val, six.integer_types) else None |
| mss_hint = int_only(orig_opts.get('MSS')) |
| wscale_hint = int_only(orig_opts.get('WScale')) |
| ts_hint = [int_only(o) for o in orig_opts.get('Timestamp', (None, None))] |
| |
| options = [] |
| if pers[4] != '.': |
| for opt in pers[4].split(','): |
| if opt[0] == 'M': |
| # MSS might have a maximum size because of window size |
| # specification |
| if pers[0][0] == 'S': |
| maxmss = (2**16-1) // int(pers[0][1:]) |
| else: |
| maxmss = (2**16-1) |
| # disregard hint if out of range |
| if mss_hint and not 0 <= mss_hint <= maxmss: |
| mss_hint = None |
| # If we have to randomly pick up a value, we cannot use |
| # scapy RandXXX() functions, because the value has to be |
| # set in case we need it for the window size value. That's |
| # why we use random.randint() |
| if opt[1:] == '*': |
| if mss_hint is not None: |
| options.append(('MSS', mss_hint)) |
| else: |
| options.append(('MSS', random.randint(1, maxmss))) |
| elif opt[1] == '%': |
| coef = int(opt[2:]) |
| if mss_hint is not None and mss_hint % coef == 0: |
| options.append(('MSS', mss_hint)) |
| else: |
| options.append(( |
| 'MSS', coef*random.randint(1, maxmss//coef))) |
| else: |
| options.append(('MSS', int(opt[1:]))) |
| elif opt[0] == 'W': |
| if wscale_hint and not 0 <= wscale_hint < 2**8: |
| wscale_hint = None |
| if opt[1:] == '*': |
| if wscale_hint is not None: |
| options.append(('WScale', wscale_hint)) |
| else: |
| options.append(('WScale', RandByte())) |
| elif opt[1] == '%': |
| coef = int(opt[2:]) |
| if wscale_hint is not None and wscale_hint % coef == 0: |
| options.append(('WScale', wscale_hint)) |
| else: |
| options.append(( |
| 'WScale', coef*RandNum(min=1, max=(2**8-1)//coef))) |
| else: |
| options.append(('WScale', int(opt[1:]))) |
| elif opt == 'T0': |
| options.append(('Timestamp', (0, 0))) |
| elif opt == 'T': |
| # Determine first timestamp. |
| if uptime is not None: |
| ts_a = uptime |
| elif ts_hint[0] and 0 < ts_hint[0] < 2**32: |
| # Note: if first ts is 0, p0f registers it as "T0" not "T", |
| # hence we don't want to use the hint if it was 0. |
| ts_a = ts_hint[0] |
| else: |
| ts_a = random.randint(120, 100*60*60*24*365) |
| # Determine second timestamp. |
| if 'T' not in pers[5]: |
| ts_b = 0 |
| elif ts_hint[1] and 0 < ts_hint[1] < 2**32: |
| ts_b = ts_hint[1] |
| else: |
| # FIXME: RandInt() here does not work (bug (?) in |
| # TCPOptionsField.m2i often raises "OverflowError: |
| # long int too large to convert to int" in: |
| # oval = struct.pack(ofmt, *oval)" |
| # Actually, this is enough to often raise the error: |
| # struct.pack('I', RandInt()) |
| ts_b = random.randint(1, 2**32-1) |
| options.append(('Timestamp', (ts_a, ts_b))) |
| elif opt == 'S': |
| options.append(('SAckOK', '')) |
| elif opt == 'N': |
| options.append(('NOP', None)) |
| elif opt == 'E': |
| options.append(('EOL', None)) |
| elif opt[0] == '?': |
| if int(opt[1:]) in TCPOptions[0]: |
| optname = TCPOptions[0][int(opt[1:])][0] |
| optstruct = TCPOptions[0][int(opt[1:])][1] |
| options.append((optname, |
| struct.unpack(optstruct, |
| RandString(struct.calcsize(optstruct))._fix()))) |
| else: |
| options.append((int(opt[1:]), '')) |
| ## FIXME: qqP not handled |
| else: |
| warning("unhandled TCP option " + opt) |
| pkt.payload.options = options |
| |
| # window size |
| if pers[0] == '*': |
| pkt.payload.window = RandShort() |
| elif pers[0].isdigit(): |
| pkt.payload.window = int(pers[0]) |
| elif pers[0][0] == '%': |
| coef = int(pers[0][1:]) |
| pkt.payload.window = coef * RandNum(min=1, max=(2**16-1)//coef) |
| elif pers[0][0] == 'T': |
| pkt.payload.window = mtu * int(pers[0][1:]) |
| elif pers[0][0] == 'S': |
| ## needs MSS set |
| mss = [x for x in options if x[0] == 'MSS'] |
| if not mss: |
| raise Scapy_Exception("TCP window value requires MSS, and MSS option not set") |
| pkt.payload.window = mss[0][1] * int(pers[0][1:]) |
| else: |
| raise Scapy_Exception('Unhandled window size specification') |
| |
| # ttl |
| pkt.ttl = pers[1]-extrahops |
| # DF flag |
| pkt.flags |= (2 * pers[2]) |
| ## FIXME: ss (packet size) not handled (how ? may be with D quirk |
| ## if present) |
| # Quirks |
| if pers[5] != '.': |
| for qq in pers[5]: |
| ## FIXME: not handled: P, I, X, ! |
| # T handled with the Timestamp option |
| if qq == 'Z': pkt.id = 0 |
| elif qq == 'U': pkt.payload.urgptr = RandShort() |
| elif qq == 'A': pkt.payload.ack = RandInt() |
| elif qq == 'F': |
| if db == p0fo_kdb: |
| pkt.payload.flags |= 0x20 # U |
| else: |
| pkt.payload.flags |= random.choice([8, 32, 40]) # P/U/PU |
| elif qq == 'D' and db != p0fo_kdb: |
| pkt /= conf.raw_layer(load=RandString(random.randint(1, 10))) # XXX p0fo.fp |
| elif qq == 'Q': pkt.payload.seq = pkt.payload.ack |
| #elif qq == '0': pkt.payload.seq = 0 |
| #if db == p0fr_kdb: |
| # '0' quirk is actually not only for p0fr.fp (see |
| # packet2p0f()) |
| if '0' in pers[5]: |
| pkt.payload.seq = 0 |
| elif pkt.payload.seq == 0: |
| pkt.payload.seq = RandInt() |
| |
| while pkt.underlayer: |
| pkt = pkt.underlayer |
| return pkt |
| |
| def p0f_getlocalsigs(): |
| """This function returns a dictionary of signatures indexed by p0f |
| db (e.g., p0f_kdb, p0fa_kdb, ...) for the local TCP/IP stack. |
| |
| You need to have your firewall at least accepting the TCP packets |
| from/to a high port (30000 <= x <= 40000) on your loopback interface. |
| |
| Please note that the generated signatures come from the loopback |
| interface and may (are likely to) be different than those generated on |
| "normal" interfaces.""" |
| pid = os.fork() |
| port = random.randint(30000, 40000) |
| if pid > 0: |
| # parent: sniff |
| result = {} |
| def addresult(res): |
| # TODO: wildcard window size in some cases? and maybe some |
| # other values? |
| if res[0] not in result: |
| result[res[0]] = [res[1]] |
| else: |
| if res[1] not in result[res[0]]: |
| result[res[0]].append(res[1]) |
| # XXX could we try with a "normal" interface using other hosts |
| iface = conf.route.route('127.0.0.1')[0] |
| # each packet is seen twice: S + RA, S + SA + A + FA + A |
| # XXX are the packets also seen twice on non Linux systems ? |
| count=14 |
| pl = sniff(iface=iface, filter='tcp and port ' + str(port), count = count, timeout=3) |
| for pkt in pl: |
| for elt in packet2p0f(pkt): |
| addresult(elt) |
| os.waitpid(pid,0) |
| elif pid < 0: |
| log_runtime.error("fork error") |
| else: |
| # child: send |
| # XXX erk |
| time.sleep(1) |
| s1 = socket.socket(socket.AF_INET, type = socket.SOCK_STREAM) |
| # S & RA |
| try: |
| s1.connect(('127.0.0.1', port)) |
| except socket.error: |
| pass |
| # S, SA, A, FA, A |
| s1.bind(('127.0.0.1', port)) |
| s1.connect(('127.0.0.1', port)) |
| # howto: get an RST w/o ACK packet |
| s1.close() |
| os._exit(0) |
| return result |
| |