blob: 6c2fc09874d14c719ec02897f66759301e0b82c2 [file] [log] [blame]
## 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