Filtering hex payload pattern at Layer 3 with iptables

The other day I was looking for a method of filtering IP packets with a certain hex payload pattern between a veth-pair on a Linux bridge. Fortunately, it turns out that iptables has this matching string module extension, i.e., --match string --algo [kmp | bm] --hex-string [pattern].

Basically, the requirement was to allow only IPv4 or IPv6 packets forwarded on this Linux bridge that had the payload pattern 0xdeadc0de. Other packets at L3, being forwarded on this bridge, should be dropped. The bridge and the veth-pair interfaces are represented in Figure 1.

Figure 1

1
2
3
4
5
6
7
8
9
10
11
/tmp
❯ brctl show
bridge name bridge id STP enabled interfaces
br-dveth0 8000.823a48ed794a no dveth0-end2
veth0
/tmp
❯ ip link show dev dveth0
[sudo] password for arcanjo:
20: dveth0@dveth0-end2: <BROADCAST,MULTICAST,PROMISC,UP,LOWER_UP> mtu 1500 qdisc noqueue stateUP mode DEFAULT group default qlen 1000
link/ether a6:4e:79:d3:26:10 brd ff:ff:ff:ff:ff:ff

Configuring iptables rules

As far IPv4 packets, these rules will do the trick, where $1 is the argument br-dveth0:

1
2
3
4
5
6
_iptables_rules(){
iptables -I FORWARD 1 -m physdev --physdev-in $1 --match string --algo kmp --hex-string '|deadc0dedeadc0de|' -j ACCEPT
iptables -I FORWARD 2 -m physdev --physdev-out $1 --match string --algo kmp --hex-string '|deadc0dedeadc0de|' -j ACCEPT
iptables -I FORWARD 3 -m physdev --physdev-in $1 -j DROP
iptables -I FORWARD 4 -m physdev --physdev-out $1 -j DROP
}

I had to insert these rules in the beginning of the FORWARD chain since this host running this Linux bridge already had other rules in place.

Now, for IPv6 packets it can get a bit tricky since at Layer 3, ICMPv6 can’t be totally filtered out, otherwise neighbor resolution wouldn’t work. Turns out, this python application, which is generating traffic with the hex 0xdeadc0de payload was not using any other type of ICMPv6 message, so it was acceptable to allow only this hex pattern and ICMPv6 messages type 135 (neighbor solicitation) and 136 (neighbor advertisement):

1
2
3
4
5
6
7
8
9
10
11
12
_ip6tables_rules(){
ip6tables -I FORWARD 1 -m physdev --physdev-in $1 --match string --algo kmp --hex-string '|deadc0dedeadc0de|' -j ACCEPT
ip6tables -I FORWARD 2 -m physdev --physdev-out $1 --match string --algo kmp --hex-string '|deadc0dedeadc0de|' -j ACCEPT
# neighbor solicitation icmpv6 type 135
ip6tables -I FORWARD 3 -m physdev --physdev-in $1 -p icmpv6 --icmpv6-type 135 -j ACCEPT
# neighbor advertisement icmpv6 type 136
ip6tables -I FORWARD 4 -m physdev --physdev-in $1 -p icmpv6 --icmpv6-type 136 -j ACCEPT
ip6tables -I FORWARD 5 -m physdev --physdev-out $1 -p icmpv6 --icmpv6-type 135 -j ACCEPT
ip6tables -I FORWARD 6 -m physdev --physdev-out $1 -p icmpv6 --icmpv6-type 136 -j ACCEPT
ip6tables -I FORWARD 7 -m physdev --physdev-in $1 -j DROP
ip6tables -I FORWARD 8 -m physdev --physdev-out $1 -j DROP
}

Tests

Now let’s run some tests to make sure it’s filtering properly. I’ll test four traffic flows:

  • IPv4 with 0xdeadc0de payload
  • IPv4 with a different payload, e.g., 0xdeadcafe
  • IPv6 with 0xdeadc0de payload
  • IPv6 with a different payload e.g., 0xdeadcafe

IPv4 network traffic

No packets have been accepted yet. The drop counter had incremented because of other random traffic present in this network:

1
2
3
4
5
6
7
8
❯ iptables -nvL
Chain FORWARD (policy ACCEPT 8 packets, 526 bytes)
pkts bytes target prot opt in out source destination
0 0 ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-in dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
0 0 ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-out dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
2 386 DROP all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-in dveth0-end2
4 772 DROP all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-out dveth0-end2

Now, after four IPv4 packets sent with 0xdeadc0de, you can double check the first counters, they were forwarded:

1
2
3
4
5
6
7
8
❯ iptables -nvL
Chain FORWARD (policy ACCEPT 20 packets, 1288 bytes)
pkts bytes target prot opt in out source destination
4 5528 ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-in dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
4 5528 ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-out dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
2 386 DROP all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-in dveth0-end2
4 772 DROP all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-out dveth0-end2

To just blow up the drop counter, I’ll generate 1000 IPv4 packets with 0xdeadcafe, as you can see the drop counter on dveth0-end2 incremented by more than 1000, still only the previous 4 packets were forwarded:

1
2
3
4
5
6
7
8
9
10
11
/tmp
❯ iptables -nvL
Chain INPUT (policy ACCEPT 113K packets, 240M bytes)
pkts bytes target prot opt in out source destination
Chain FORWARD (policy ACCEPT 36 packets, 2286 bytes)
pkts bytes target prot opt in out source destination
4 5528 ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-in dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
4 5528 ACCEPT all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-out dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
1004 1383K DROP all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-in dveth0-end2
8 1544 DROP all -- * * 0.0.0.0/0 0.0.0.0/0 PHYSDEV match --physdev-out dveth0-end2

IPv6 network traffic

Similarly to the previous tests with IPv4, let’s start with four IPv6 packets with 0xdeadc0de payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
/tmp
❯ ip6tables -nvL
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
4 5528 ACCEPT all * * ::/0 ::/0 PHYSDEV match --physdev-in dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
4 5528 ACCEPT all * * ::/0 ::/0 PHYSDEV match --physdev-out dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
1 72 ACCEPT icmpv6 * * ::/0 ::/0 PHYSDEV match --physdev-in dveth0-end2 ipv6-icmptype 135
1 72 ACCEPT icmpv6 * * ::/0 ::/0 PHYSDEV match --physdev-in dveth0-end2 ipv6-icmptype 136
1 72 ACCEPT icmpv6 * * ::/0 ::/0 PHYSDEV match --physdev-out dveth0-end2 ipv6-icmptype 135
1 72 ACCEPT icmpv6 * * ::/0 ::/0 PHYSDEV match --physdev-out dveth0-end2 ipv6-icmptype 136
1 96 DROP all * * ::/0 ::/0 PHYSDEV match --physdev-in dveth0-end2
22 2081 DROP all * * ::/0 ::/0 PHYSDEV match --physdev-out dveth0-end2

Neighbor advertisement and neighbor solicitation packets were forwarded too. Now, let’s generate 1000 IPv6 packets with 0xdeadcafe, as you can see, there was an increase of more than 1000 in the drop counter:

1
2
3
4
5
6
7
8
9
10
11
12
13
/tmp
❯ ip6tables -nvL
Chain FORWARD (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
4 5528 ACCEPT all * * ::/0 ::/0 PHYSDEV match --physdev-in dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
4 5528 ACCEPT all * * ::/0 ::/0 PHYSDEV match --physdev-out dveth0-end2 STRING match "|deadc0dedeadc0de|" ALGO name kmp TO 65535
2 144 ACCEPT icmpv6 * * ::/0 ::/0 PHYSDEV match --physdev-in dveth0-end2 ipv6-icmptype 135
2 144 ACCEPT icmpv6 * * ::/0 ::/0 PHYSDEV match --physdev-in dveth0-end2 ipv6-icmptype 136
2 144 ACCEPT icmpv6 * * ::/0 ::/0 PHYSDEV match --physdev-out dveth0-end2 ipv6-icmptype 135
2 144 ACCEPT icmpv6 * * ::/0 ::/0 PHYSDEV match --physdev-out dveth0-end2 ipv6-icmptype 136
1001 1382K DROP all * * ::/0 ::/0 PHYSDEV match --physdev-in dveth0-end2
28 2783 DROP all * * ::/0 ::/0 PHYSDEV match --physdev-out dveth0-end2

Awesome! The filtering in place is working at the kernel level, filtering at layer 3 as expected.