Last week I’ve found that ISP I use on one of my sites blocks IPsec connections from the said site to one of my other sites - IPsec connections starts, then stalls. Monitoring the connection from both sides suggests that ISP starts to drop the packets as soon as the connections is identified as IPsec, other connections are not blocked, and port numbers have nothing to do with blocking. Wireguard is also blocked, and I haven’t bothered to try OVPN because I’m sure it’s blocked too. SSTP surely works, but it lacks HW offload on the hardware used, so I’ve decided to dig deeper.
After some research and proof-of-concept manual DPI defeat with traffic generator I’ve developed a script that automatically defeats the kind of DPI used by this ISP (and I’m quite sure some others):
UPDATE:
ver. 0.2 (May 8th 2025) - I’ve encountered more DPI-based filters on other ISPs, so I’ve updated the script:
- Peer activity check now matches on both remote address and remote port (only remote address was matched in ver. 0.1, though ROS 7.18 still have both local and remote ports named “port” in properties of active-peers);
- Improved default gateway address detection due to /ip route check returned in ROS 7.18;
- The script now honors the local address used for peer connections, so it will work when you’re establishing several IPsec connections to the same host from the different local addresses.
# DPI defeat for IPsec ver. 0.2 (May 8th 2025) by 611
# Tested on ROS 7.18.2
# It's been found that some active DPI systems are behaving much like ROS firewall's L7 protocol matcher - they track the
# connections and analyze initial data (data size and/or number of packets may vary) to determine if the connection matches the
# criteria (if the connection is some form of VPN, etc.), connections matched could be throttled or blocked depending on ISP's
# intent. This peculiarity allows to defeat these DPIs by sending some dummy traffic (that won't match with the patterns DPI
# looks for) at the start of connection. The dummy traffic could have a lower TTL so it won't reach the server.
#
# This method ONLY works in the said circumstances, it WON'T WORK if ISP matches IP address, destination port, etc.
# The script implements such DPI defeat for IPsec connections. The IPsec peers to be processed are marked with "DPI-D" in
# their comment. The script will work with several IPsec peers requiring DPI defeat, but isn't designed to be reenterable.
# You'll normally want to start it with scheduler on regular intervals (the interval should be much longer than the time
# required to establish _all_ affected IPsec connections):
#
# /system scheduler add name=sched-IPsec-DPI-defeat on-event="/system script run scr-IPsec-DPI-defeat" policy=read,write,test,sniff interval=30s
#
# The script will check if IPsec connection was successful, and if it's not it will create or modify traffic generator packet
# template named "tgpt-IPsec-DPI-defeat", use it to send specified number of packets (of specified size and TTL, with specified
# tempo) with dummy data (incrementing bytes, modify this if it doesn't work for you) towards the peer from a random port within
# the specified range, then create or modify IP firewall NAT rule (with comment "DPI-D for <peer name>") so IPsec UDP packets
# towards the peer will come from the port that was used for sending dummy traffic. Existing connection will be purged from
# connection tracker to apply the new NAT rule. The NAT rule placement is important, so you should create an anchor rule:
#
# /ip firewall nat add chain=srcnat action=passthrough comment="--- DPI-D rules go before this line"
# The script requires read, write, test and sniff permissions, and traffic-gen enabled in /system device mode (since ROS 7.17)
# Known limitations:
# * Not tested with non-IKE2 IPsec peers
# * No IPv6 support
# * No support for multiple A/AAAA records for peer's FQDN
# * No support for multiple peers on the same IP address unless different local IP addresses are used to connect to them
# * Common dummy packet parameters for all peers (not sure if individual parameters is needed)
# * Local address and gateway address detection are sketchy
#
# Known side effects:
# * Traffic generator is started then stopped
# * Traffic generator packet template named "tgpt-IPsec-DPI-defeat" remains in the configuration
# * IP firewall NAT rules remain in the configuration (and could become orphan if IPsec peer name were to change)
# Version History:
#
# ver. 0.1 (Jun 12th 2024) - Initial release.
#
# ver. 0.2 (May 8th 2025) - I've encountered more DPI-based filters on other ISPs, so I've updated the script:
# + Peer activity check now matches on both remote address and remote port (only remote address was matched in ver. 0.1, though
# ROS 7.18 still have both local and remote ports named "port" in properties of active-peers);
# + Improved default gateway address detection due to /ip route check returned in ROS 7.18;
# + The script now honors the local address used for peer connections, so it will work when you're establishing several IPsec
# connections to the same host from the different local addresses.
# === Settings
# Size and number of packets to send, TTL to send with, and sending tempo
:local packetsize 512
:local packetcount 128
:local packetttl 64
:local packetspersecond 32
# IP firewall NAT anchor rule comment (the NAT rule will be added before the rule with this comment)
:local anchornatrulecomment "--- DPI-D rules go before this line"
# Local port range used for connections, at least 100 ports
:local rndportstart 28847
:local rndportend 28946
# === Code
# Loop thru enabled IPsec peers with "DPI-D" in comment
:foreach curpeer in=[/ip ipsec peer find comment~"DPI-D" !disabled] do={
# Get the peer configuration needed, storing the last error
:local peerconfiglasterror "no"
# Get the peer name
:local peername [/ip ipsec peer get $curpeer name]
# Select the local port based on exchange mode (TBD: test it with non-IKE2)
:local peerlocalport
:if ([/ip ipsec peer get $curpeer exchange-mode] = "ike2") do={ :set peerlocalport 4500 } else={ :set peerlocalport 500 }
# Get the peer address, resolve it if it's a FQDN (TBD: multiple A/AAAA records)
:local peeraddress [/ip ipsec peer get $curpeer address]
:do { :if ([:typeof $peeraddress] = "str") do={ :set $peeraddress [:resolve $peeraddress] }} on-error={ :set peerconfiglasterror "peer FQDN not resolved" }
# Get the peer port, default to the local port if not specified
:local peerport [/ip ipsec peer get $curpeer port]
:if ([:typeof $peerport] != "num") do={ :set peerport $peerlocalport }
# Get the parameters of the current default (sic!) route used for connections to this peer (made easy with /ip route check return
# in ROS 7.18!)
:local peerroutecheck [/ip route check dst-ip=$peeraddress as-value once]
# Get the local address and gateway used for connections to this peer
:local peerlocaladdress [:toip [/ip ipsec peer get $curpeer local-address]]
:local peergateway ($peerroutecheck->"nexthop")
# If the local address IS NOT specified in the peer properties we will need to find it from the gateway address for the peer
# address by traversing the active IP addresses on the interface returned by /ip route check, and checking if the gateway is
# within the network for the particular address
:if ([:typeof $peerlocaladdress] != "ip") do={
:foreach curaddress in=[/ip address find interface=($peerroutecheck->"interface") !disabled] do={
:local curip [/ip address get $curaddress address]
# Yep, the only way to get a ip-prefix typed value from string is to [[:parse (":return ".<string>)]]
:if ($peergateway in [[:parse (":return ".[/ip address get $curaddress network].[:pick $curip [:find $curip "/"] [:len $curip]])]]) do={ :set peerlocaladdress [:toip [:pick $curip 0 [:find $curip "/"]]]}
# If the local address IS specified in the peer properties, the connection to the peer is likely routed with route other than
# default, returned by /ip route check, and we will need to find a correct gateway for the peer by traversing the interfaces
# with IP address equal to the local address used for the peer, then finding the routes that use these interfaces and are
# appropriate for the peer address. The gateway address is in the first part of the immediate gateway property of the route
}} else={
:set peergateway
:foreach curaddress in=[/ip address find address~"($peerlocaladdress)" !disabled] do={
:foreach curroute in=[/ip route find immediate-gw~([/ip address get $curaddress interface]) active !disabled] do={
:local curimmgw [:tostr [/ip route get $curroute immediate-gw]];
:if ($peeraddress in [/ip route get $curroute dst-address]) do={ :set peergateway [:toip [:pick $curimmgw 0 [:find $curimmgw "%"]]]}
}}}
# Check if there were any problems determining local address and gateway for the peer
:if ([:typeof $peerlocaladdress] != "ip") do={ :set peerconfiglasterror "can't determine the local address used with the peer" }
:if ([:typeof $peergateway] != "ip") do={ :set peerconfiglasterror "no route to the peer" }
# Debug output for the peer config
:global DEBUG
:if ([:typeof $DEBUG] != "nothing") do={ :put ($peername . "'s configuration: " . $peerlocaladdress . ":" . $peerlocalport . " -" . $peergateway . "-> " . $peeraddress . ":" . $peerport)}
# Proceed with operation if there's no errors in configuration and no active connection with the peer
# Note that both local port and remote port properties of the active peers are named "peer", but the "find" command matches with
# remote port as needed in this scenario
:if (($peerconfiglasterror = "no") && ([:len [/ip ipsec active-peers find state="established" local-address=$peerlocaladdress remote-address=$peeraddress port=$peerport]] = 0)) do={
# Generate a random port number within range
# Note there's no check if the new port is the same as the old one as the probability is low enough
:local rndport [:rndnum from=$rndportstart to=$rndportend]
# Message
:put ($peername . " is not up, will try from port " . $rndport)
# Set up traffic generator packet template (your header stack may vary depending on interface used,
# you may try to use payload other than incrementing bytes)
# Add or modify the template depending on if it already exists or not
:if ([:len [/tool traffic-generator packet-template find name="tgpt-IPsec-DPI-defeat"]] = 0) do={
/tool traffic-generator packet-template add name="tgpt-IPsec-DPI-defeat" header-stack=mac,ip,udp data=incrementing ip-gateway=$peergateway ip-dst=$peeraddress ip-ttl=$packetttl udp-src-port=$rndport udp-dst-port=$peerport
} else={
# Duplicate names are not allowed, so there's no need to check if there's more than one template exists
# IP TTL is not modified as it's not expected to change
/tool traffic-generator packet-template set [find name="tgpt-IPsec-DPI-defeat"] ip-gateway=$peergateway ip-dst=$peeraddress udp-src-port=$rndport udp-dst-port=$peerport
}
# Send it! (yep, there's a "packet-count" parameter in the command line)
/tool traffic-generator start tx-template="tgpt-IPsec-DPI-defeat" packet-size=$packetsize pps=$packetspersecond packet-count=$packetcount
# Wait for completion + 1 second, stop the traffic generator
:delay ($packetcount / $packetspersecond + 1s)
/tool traffic-generator stop
# Set up firewall NAT rule so IPsec will use the same port as traffic generator
# Add or modify the NAT entry depending on if it already exists or not (we assume the parameters haven't changed from the previous try)
# Note that protocol, ports and addresses must be enclosed in quotation marks for /ip firewall nat find to work properly
:if ([:len [/ip firewall nat find chain=srcnat action=src-nat comment="DPI-D for $peername"]] = 0) do={
/ip firewall nat add place-before=[find comment="$anchornatrulecomment"] chain=srcnat action=src-nat protocol=udp src-address=$peerlocaladdress src-port=$peerlocalport dst-address=$peeraddress dst-port=$peerport to-ports=$rndport comment="DPI-D for $peername"
} else={
# Set command will modify all matching rules , so there's no need to check if there's more than one rule exists
/ip firewall nat set [find chain=srcnat action=src-nat comment="DPI-D for $peername"] protocol="udp" src-address=$peerlocaladdress src-port=$peerlocalport dst-address=$peeraddress dst-port=$peerport to-ports=$rndport
}
# Remove old connection from connection tracker to apply new rule
/ip firewall connection remove [find dst-address="$peeraddress:$peerport"]
# No cleanup:
# - traffic generator packet template is not removed to avoid unnecessary log entries - we'll need it for next one anyways
# - IP firewall NAT rule must stay for IPsec connection to function
# Message otherwise
} else={ :if ($peerconfiglasterror = "no") do={ :put ($peername . " is up, skipping") } else={ :put ($peername . " has a configuration error: " . $peerconfiglasterror) } }
}
# Checkmark (useful to determine if the script has executed in whole)
:put ("Done.");
Feel free to use, modify and distribute.
Comment and suggestions are welcome.