Hey! It’s been a while since our last post, but be on the lookout for drops of CTF writeups, security research project updates, and more.
To start off we have a X-Post from ghost’s personal blog. The orginal lives here.
A recent post to the OSS Security mailing list brought up a potential DoS fixed in Linux about a year ago. This got a decent amount of attention on Twitter, and so I decided to see if I could create a proof-of-concept for this relatively simple bug.
Before we get started, it’s worthwhile pointing out that while this is a single packet remote DoS on affected boxes, it’s very unlikely any systems will be affected. It requires a number of things to trigger:
- A vulnerable version of Linux (4.0-4.13)
- With SELinux enabled
- With at least 1 CIPSOv4 rule added
- And a service listening on a port (with iptables rules added)
Going into detail on each of these requirements, the only common distros with vulnerable kernels are (as far as I can tell with some quick googling):
- Ubuntu 16.04
- OpenSUSE Leap 15
- Debian Stretch
- RHEL/CentOS (with ELRepo kernel-lt)
Out of these, only RHEL/CentOS use SELinux by default (the others use AppArmor). Because of this, we’re already down to a single distro running a non-stock kernel, and we haven’t even gotten to the “weird” requirement yet:
- 1 CIPSOv4 rule added
CIPSO (Commercial IP Security Option) is an IP option that is supposed to allow for machines to communicate the “security” of net flows to other servers (e.g. DoD classification level). This IP option was never actually standardized and only remains as an expired IETF draft today, yet it is still implemented in Linux.
Given that this option was never fully standardized, I highly doubt many are using it in production networks.
The commit referenced in the mailing list post seems to explain exactly how to reproduce the bug:
Test: receive a packet which the ip length > 20 and the first byte of ip option is 0, produce this issue
Comparing this to what was changed in the commit doesn’t quite add up though. Based on the changes, it’s obvious that we enter a spinloop (DoS) condition when the length of an option is 0, but the length is stored in the second byte of the option. Setting an option length to 0 still seems like the way to go though, so I put together a small Scapy script with a “Stream Identifier” option (type 136), but forcefully set the length byte to 0:
... opt_type = chr(136) opt_len = '\x00' stream_id = '\x00\x01' ip.options = [IPOption(opt_type + opt_len + stream_id)] ...
This didn’t work immediately, so I needed to dig a bit more. I started by using the
kprobe tool from perf-tools to check if the function was being hit at all. And it wasn’t.
At this point I figured I needed to somehow activate this subsystem since I wasn’t seeing any hits at all. I worked my way up the call tree from the vulnerable function (
cipso_v4_optptr) using elixir and found that it was called by a few functions in
net/netlabel/netlabel_kapi.c, the first of which (
netlbl_skbuff_getattr) seemed to be the most interesting (based on the name).
This was in turn called by
selinux_netlbl_skbuff_getsid, which calls
netlbl_enabled before doing anything, presumably so that the kernel doesn’t unnecessarily burn cycles looking for CIPSO headers when it wouldn’t do any processing based on them anyways.
netlbl_enabled, I realized I probably needed to add a CIPSO rule for this entire subsystem to activate.
Sure enough, after installing and fumbling around with
netlabelctl to add the rule, I started seeing kprobe hits on
cipso_v4_optptr with other network traffic so I knew I was headed the right direction. But the exploit still didn’t work :(
And curiously, I didn’t see any kprobe hits when my PoC was firing…
Given everything I had seen (and not seen) at this point, I figured that something upstream of
netlbl_skbuff_getattr was doing some sanity checking on the IP option lengths to make sure they were valid. A bit more testing with other IP options seemed to confirm this, so I began hunting for the place this validation was happening and found some code in
net/ipv4/ip_options.c which looks exactly like the validation code I was expecting to find (see
While I don’t think (due to the name of the function) that this is the function actually doing the opt len checking on each packet, this brought something to my attention: not only is
IPOPT_END a special case, but
IPOPT_NOOP is as well! Both of these are checked for before length checking is done, and reviewing the original Internet Protocol RFC confirms that these are the 2 IP options that do not have a length field:
The option field is variable in length. There may be zero or more options. There are two cases for the format of an option: Case 1: A single octet of option-type. Case 2: An option-type octet, an option-length octet, and the actual option-data octets. ... The following internet options are defined: CLASS NUMBER LENGTH DESCRIPTION ----- ------ ------ ----------- 0 0 - End of Option list. This option occupies only 1 octet; it has no length octet. 0 1 - No Operation. This option occupies only 1 octet; it has no length octet.
But of course
cipso_v4_optptr doesn’t take the NO-OP special case into account.
At this point I realized that since both NO-OP and END are 1 octet/byte, and that the END opt is type 0, if we have a NO-OP option then an END option, the properly implemented option checking upstream would validate this, but the CIPSO code would attempt to decode the END option (which, as type 0, is just the NULL byte) as a length for the NO-OP option, giving us a spinloop!
Modifying the first attempt’s code to test this is trivial (the END opt is automatically added by Scapy):
ip.options = [IPOption('\x01')]
Bingo! A stall.
To clean things up for the Twitter PoC I searched around a bit and found that Scapy has some helper classes for IP options, one of which (
IPOption_NOP) does everything we need. And so the final exploit small enough to fit in a tweet looks like this:
from scapy.all import * import sys ip = IP(dst=sys.argv, options=[IPOption_NOP()]) udp = UDP(sport=12345, dport=12345) pkt = ip/udp/"TEST" sr1(pkt, verbose=0)
This successfully hangs a CentOS 7 box that’s fully upgraded with ELRepo kernel-lt (as of 28 Aug 2018), a single CIPSO rule added, and a netcat instance listening on UDP 12345 with the necessary iptables/firewall-cmd rules added.
While this bug won’t affect many boxes due to the obscure setup required to be vulnerable, it was nevertheless a fun way to spend a night triaging, debugging, reproducing, and eventually exploiting the bug. It also gave me some interesting ideas about ways to automatically detect these types of simple DoS bugs which I hope to cover in the future (hopefully after writing some tooling).