SDN2022/SDN-Firewall/firewall.py

358 lines
14 KiB
Python

# A self-adaptive SDN firewall, automatically setting the filtering rules
# as a response to threats detected.
#
# It (individually) acts like a stateless packet filtering, working with
# statically configured rules. Equipped with a powerful IDS or other external
# traffic analyzers, it can be stateful, more like an IPS.
#
# In this project, it integrates a smart traffic analyzer. Its workflow are three
# loops as below:
#
# Packet Forwarding Loop: Instruct the switch to mirror all packets.
# 1. Packet In.
# 2. Find for P the highest matched filtering rule R.
# 3. Do nothing if no rule matches.
# 4. Decide the output port for P based on R's action.
# - accept. Output as usual. That is, output to the port learned before or FLOOD
# if the destination has not been learned.
# - redirect. Output to the pre-configured redirect port.
# - drop. Do nothing.
# 5. Add the pre-configured IDS port to the output port list if not FLOOD.
# 6. Packet Out.
#
# Alerting Loop: Update filtering rules whenever altered by the analyzer.
# 1. Receive from the analyzer a label L for a certain packet as a deferred response.
# 2. Decide an action A based on L and IDS rules (a lable-action table).
# 3. Modify filtering rules based on A. That is, insert the new rule ahead of
# existing rules and remove all conflicting old rules.
# 4. APPLY ALL filtering rules to all switches. That is, clear all flow entries, add
# the table-miss entry back, and install a flow entry for each filtering rule.
# - accept/redirect. Output as in Packet Forwarding Loop.
# - drop. Drop explicitly (i.e. add a flow entry with CLEAR_ACTION).
#
# Admin Loop: APPLY ALL filtering rules once rules are committed from web admin.
# - The correctness is ensured by the user who submitted the modification.
# - The APPLY ALL operation is exactly the same as in Alerting Loop.
from utils import *
from ryu.base import app_manager
from ryu.controller import ofp_event
from ryu.controller.handler import CONFIG_DISPATCHER, MAIN_DISPATCHER
from ryu.controller.handler import set_ev_cls
from ryu.ofproto import ofproto_v1_3
from ryu.lib.packet import packet
from ryu.lib.packet import ethernet, ether_types, in_proto
from ryu.lib.packet import ipv4
from ryu.lib.packet import tcp, udp, arp, icmp
from ryu.app.ofctl.api import get_datapath
import csv # cope with firewall.rule and ids.rule
firewall_rule_file = "./rules/firewall.rule"
ids_rule_file = "./rules/ids.rule"
log_file = "./log/alert.pkt"
class BasicFirewall(app_manager.RyuApp):
OFP_VERSIONS = [ofproto_v1_3.OFP_VERSION]
_CONTEXTS = {'alerter' : AlertObserver, 'reminder' : RuleReminder}
def __init__(self, *args, **kwargs):
super(BasicFirewall, self).__init__(*args, **kwargs)
self.name = "firewall"
self.ip_to_port = {}
# self.snort_port = 3
self.redirect_port = 4 # TODO: consider being configurable!
self.ids_port = 5
self.alerter = kwargs['alerter']
# self.alerter.start() # start() has been implicitly called here
self.reminder = kwargs['reminder']
@set_ev_cls(ofp_event.EventOFPSwitchFeatures, CONFIG_DISPATCHER)
def switch_features_handler(self, ev):
datapath = ev.msg.datapath
ofproto = datapath.ofproto
parser = datapath.ofproto_parser
# install table-miss flow entry in reset_flow_table()
# which would be called by apply_rules_for_all()
self.apply_rules_for_all()
def add_flow(self, datapath, priority, match, actions, buffer_id=None):
ofproto = datapath.ofproto
parser = datapath.ofproto_parser
if actions:
inst = [parser.OFPInstructionActions(ofproto.OFPIT_APPLY_ACTIONS,
actions)]
else:
# flow entry for drop action
inst = [parser.OFPInstructionActions(ofproto.OFPIT_CLEAR_ACTIONS, [])]
kwargs = dict(datapath=datapath, priority=priority, match=match,
instructions=inst, command=ofproto.OFPFC_ADD)
if buffer_id:
kwargs['buffer_id'] = buffer_id
# exclude table-miss entry
# if priority > 0:
# kwargs['idle_timeout'] = 5
mod = parser.OFPFlowMod(**kwargs)
datapath.send_msg(mod)
def reset_flow_table(self, datapath):
# remove all flow entries and install table-miss entry back
ofproto = datapath.ofproto
parser = datapath.ofproto_parser
# remove all flow entries
# uncommented parameters are indispensable to clear
kwargs = dict(datapath=datapath,
command=ofproto.OFPFC_DELETE,
# priority=1,
# buffer_id=ofproto.OFP_NO_BUFFER,
out_port=ofproto.OFPP_ANY,
out_group=ofproto.OFPG_ANY,
# flags=ofproto.OFPFF_SEND_FLOW_REM,
# match=parser.OFPMatch(),
# instructions=[]
)
mod = parser.OFPFlowMod(**kwargs)
datapath.send_msg(mod)
# install table-miss entry
match = parser.OFPMatch()
actions = [parser.OFPActionOutput(ofproto.OFPP_CONTROLLER,
ofproto.OFPCML_NO_BUFFER)]
self.add_flow(datapath, 0, match, actions)
def get_protocols(self, pkt):
protocols = {}
for p in pkt:
if hasattr(p, 'protocol_name'):
protocols[p.protocol_name] = p
else:
protocols['payload'] = p
return protocols
@set_ev_cls(ofp_event.EventOFPPacketIn, MAIN_DISPATCHER)
def _packet_in_handler(self, ev):
# If you hit this you might want to increase
# the "miss_send_length" of your switch
if ev.msg.msg_len < ev.msg.total_len:
self.logger.debug("packet truncated: only %s of %s bytes",
ev.msg.msg_len, ev.msg.total_len)
msg = ev.msg
datapath = msg.datapath
ofproto = datapath.ofproto
parser = datapath.ofproto_parser
in_port = msg.match['in_port']
pkt = packet.Packet(msg.data)
eth = pkt.get_protocols(ethernet.ethernet)[0]
if eth.ethertype == ether_types.ETH_TYPE_LLDP:
# ignore lldp packet
return
# dst = eth.dst
# src = eth.src
dpid = format(datapath.id, "d").zfill(16)
self.ip_to_port.setdefault(dpid, {}) # ip learning is more efficient
# self.logger.info("packet in %s %s %s %s", dpid, src, dst, in_port)
# ignoring others except ipv4-tcp packets
protocols = self.get_protocols(pkt)
if 'ipv4' not in protocols or 'tcp' not in protocols:
return
p_ipv4 = protocols['ipv4']
p_tcp = protocols['tcp']
# learn an ip address (rather than mac) to avoid FLOOD next time
# or TODO: consider maintaining an ip-mac-port table to support learn mac from non-ip packet
self.ip_to_port[dpid][p_ipv4.src] = in_port
# log packet-in event
FirewallLogger.recordPacketInEvent(p_ipv4.src, p_tcp.src_port, p_ipv4.dst, p_tcp.dst_port)
# start filtering
blocked = True
# typical firewall rules as:
# id, s_ip, s_port, d_ip, d_port, action
# 1, 10.0.0.1, 80, 10.0.0.2, any, accept
# 2, 10.0.0.2, any, 10.0.0.3, 135, redirect
with open(firewall_rule_file) as frfile:
rules = list(csv.DictReader(frfile))
for r in rules:
# get actions for forward and backward rule if matched
# no rule will be applied to switches in packet-in event now
# thus the priority parameter makes no difference
actions1, actions2 = self.apply_rule_for(datapath, r, -1, False)
# check if current packet matches
# match only once, i.e., match the highest one
# forward rule matched
if r['s_ip'] == "any" or r['s_ip'] == p_ipv4.src:
if r['s_port'] == "any" or r['s_port'] == p_tcp.src_port:
if r['action'] != "drop":
blocked = False
actions = actions1
matched = True
FirewallLogger.recordRuleEvent("match", r)
break
# backward fule matched
if r['d_ip'] == "any" or r['d_ip'] == p_ipv4.src:
if r['d_port'] == "any" or r['d_port'] == p_tcp.src_port:
if r['action'] != "drop":
blocked = False
actions = actions2
matched = True
FirewallLogger.recordRuleEvent("match", r)
break
if not blocked:
# forward current packet, actions have been determined if not blocked
data = None
if msg.buffer_id == ofproto.OFP_NO_BUFFER:
data = msg.data
out = parser.OFPPacketOut(datapath=datapath, buffer_id=msg.buffer_id,
in_port=in_port, actions=actions, data=data)
datapath.send_msg(out)
@set_ev_cls(EventAlert, MAIN_DISPATCHER)
def handle_alert(self, ev):
# message <label, s_ip, s_port, d_ip, d_port, data>
# typical alert messages in file as:
# DOS, 10.0.0.1, 80, 10.0.0.2, 445, some-serialized-bytes
msg = ev.msg
# label-action configuration
# typical lines as:
# DOS, drop
# R2L, redirect
action = getActionForLabel(msg.label)
# DONE: consider clearing related or all flow entries immediately
FirewallLogger.recordAlertEvent(msg)
if action == "alert":
self._handle_alert(msg)
elif action == "drop":
self._handle_drop(msg)
elif action == "redirect":
self._handle_redirect(msg)
def _handle_alert(self, msg):
self.logger.info("[alert][%s] %s:%d --> %s:%d, data = {", msg.label,
msg.s_ip, msg.s_port, msg.d_ip, msg.d_port)
print(" ".join(["%02x" % ord(ch) for ch in msg.data]))
self.logger.info("}")
def _handle_drop(self, msg):
self.logger.info("[drop][%s] %s:%d --> %s:%d", msg.label,
msg.s_ip, msg.s_port, msg.d_ip, msg.d_port)
# insert <id, s_ip, s_port, any, any, drop>
kwargs = dict(ruletype = "firewall",
s_ip = msg.s_ip, s_port = msg.s_port,
d_ip = "any", d_port = "any", action = "drop")
RuleWriter.insert_ahead(**kwargs)
self.apply_rules_for_all()
def _handle_redirect(self, msg):
self.logger.info("[redirect][%s] %s:%d --> %s:%d", msg.label,
msg.s_ip, msg.s_port, msg.d_ip, msg.d_port)
# insert <id, s_ip, s_port, any, any, redirect>
kwargs = dict(ruletype = "firewall",
s_ip = msg.s_ip, s_port = msg.s_port,
d_ip = "any", d_port = "any", action = "redirect")
RuleWriter.insert_ahead(**kwargs)
self.apply_rules_for_all()
@set_ev_cls(EventRuleModified, MAIN_DISPATCHER)
def handle_rule_alert(self, ev):
self.apply_rules_for_all()
FirewallLogger.recordAdminSubmit()
def apply_rules_for_all(self):
datapaths = get_datapath(self)
for datapath in datapaths:
self.reset_flow_table(datapath) # install table-miss entry here
with open(firewall_rule_file) as frfile:
rules = list(csv.DictReader(frfile))
priority = len(rules)
for r in rules:
self.apply_rule_for(datapath, r, priority)
priority -= 1
def apply_rule_for(self, datapath, rule, priority, applied=True):
dpid = format(datapath.id, "d").zfill(16)
ofproto = datapath.ofproto
parser = datapath.ofproto_parser
rid = rule['id']
s_ip = rule['s_ip']
s_port = rule['s_port']
d_ip = rule['d_ip']
d_port = rule['d_port']
act = rule['action']
self.logger.info("Fetch rule %s: %s:%s --> %s:%s %s",
rid, s_ip, s_port, d_ip, d_port, act)
# add flow entry for all rules (even drop)
if act:
# forward rule
kwargs1 = dict(eth_type=ether_types.ETH_TYPE_IP,
ip_proto=in_proto.IPPROTO_TCP)
# backward rule
kwargs2 = dict(eth_type=ether_types.ETH_TYPE_IP,
ip_proto=in_proto.IPPROTO_TCP)
# ignore "any"
if(s_ip != "any"):
kwargs1["ipv4_src"] = kwargs2["ipv4_dst"] = s_ip
if(s_port != "any"):
kwargs1["tcp_src"] = kwargs2["tcp_dst"] = int(s_port)
if(d_ip != "any"):
kwargs1["ipv4_dst"] = kwargs2["ipv4_src"] = d_ip
if(d_port != "any"):
kwargs1["tcp_dst"] = kwargs2["tcp_src"] = int(d_port)
match1 = parser.OFPMatch(**kwargs1)
match2 = parser.OFPMatch(**kwargs2)
# find learned ip respectively
out_port1 = out_port2 = ofproto.OFPP_FLOOD
if dpid in self.ip_to_port:
if d_ip in self.ip_to_port[dpid]:
out_port1 = self.ip_to_port[dpid][d_ip]
if s_ip in self.ip_to_port[dpid]:
out_port2 = self.ip_to_port[dpid][s_ip]
if act == "redirect":
out_port1 = out_port2 = self.redirect_port
# mirror all later-matched packets to ids
actions1 = [parser.OFPActionOutput(out_port1)]
if out_port1 != ofproto.OFPP_FLOOD:
actions1.append(parser.OFPActionOutput(self.ids_port))
actions2 = [parser.OFPActionOutput(out_port2)]
if out_port2 != ofproto.OFPP_FLOOD:
actions2.append(parser.OFPActionOutput(self.ids_port))
if act == "drop":
actions1 = actions2 = [] # TODO: consider forwarding to ids only
if applied:
self.add_flow(datapath, priority, match1, actions1)
self.add_flow(datapath, priority, match2, actions2)
return actions1, actions2