iCTF scorebot bugfixes

Recently, I've been looking into the python code of the iCTF ctfbot, because we are slapping together a new CTF team at our CS dept.
The ctfbot works pretty much out of the box... if you use a standard network setup.
Of course, for reasons I won't go into right now, we don't have a standard network setup.

CTF and our setup


A CTF game consists of a number of teams competing against eachother. Each of the teams has a server provided by the CTF organisers, and any number of client computers that the team provides itself. The provided server is vulnerable in a number of ways. The task of each team, is to defend their server from attackers, and to attack the other teams' servers and exploit the vulnerabilities. A central scorebot keeps an eye on all the servers, to make sure that nobody is cheating by e.g. just disabling the vulnerable services.

the iCTF ctfbot is a scorebot that is freely downloadable and can be used with the iCTF server images of preceding years. The bot must be configured for each team with the location of the team's server, and the network range of the team clients computers.

And this is where our freaky setup comes into play. In this particular case, we have 2 CTF images running. One server runs on 192.168.10.1, the other on 192.168.12.1. Two teams (called ChuckNorris and Superman) are located on their own networks. One on 192.168.2.0/25, the other on 192.168.2.128/25. Each team thus has about 128 addresses available (minus some for network housekeeping and routing)

Looking at the code, the iCTF ctfbot seems to assume that both the server and the clients are in the same IP range. Because of this, I wrote a patch to fix that, and to be able to specify multiple IP ranges belonging to a single team. In doing so, I came across a bug in the ctfbot that seems to be based on a common misconception. The bug has to do with CIDR network masks and how they are calculated and matched against IP addresses.

Let's have a look at this code (in scorebot/standard/submitbot/SubmitWebserver.py):


def extractNetworkValue(ip_txt,masksize):
        mask = (2L<
        ip = struct.unpack('I',socket.inet_aton(ip_txt))[0]
        return ip & mask


This function takes an IP address in textual form (e.g. "192.168.2.6") and a masksize in integer form (e.g. 25). The task of this function is to extract the base network address.

For the values described here, this calculation goes as follows (you can use the ipcalc program to verify this):
A network with masksize 25 has a network mask of 255.255.255.128. IP addresses on today's internet are 32bit in size. The dotted-decimal notation of an IP address is the most common way to write such a netmask, but when dealing with CIDR netmasks, it makes more sense to write them in binary:

255.255.255.128 = 11111111 11111111 11111111 10000000

Similarly, the IP address can be written in binary notation:

192.168.2.6     = 11000000 10101000 00000010 00000110

To calculate the base network address for this IP and this netmask, one must simply do a logical AND:


  11111111 11111111 11111111 10000000
  11000000 10101000 00000010 00000110
& -----------------------------------
  11000000 10101000 00000010 00000000

Notice how the last 7 bits are set to 0 because of this logical AND.

What is this base address used for? Well, it can be used by routers to determine whether a certain IP address falls in some range. The network base address of 192.168.2.6/25 is 192.168.2.0, while for e.g 192.168.2.200/25 it would be 192.168.2.128. The calculation done in the ctfbot code serves the same purpose. When a team submits a flag to the scorebot, the scorebot checks the base address of the IP address from which the submission came. If the scorebot finds that the IP address is not in any registered IP range, it responds with "Flag was submitted from an IP not associated with any team!"


Wrong netmask calculation


Let's take a closer look at the code now. The function first calculates the network mask, based on its size with the formula mask = (2L<

The following python oneliner will print out the binary notation of this mask for values ranging from 1 to 33(exclusive):

python -c 'for masksize in range(1, 33): print "/%2d: %s" % (masksize, "{0:032b}".format((2L<


/ 1: 00000000000000000000000000000001
/ 2: 00000000000000000000000000000011
/ 3: 00000000000000000000000000000111
/ 4: 00000000000000000000000000001111
/ 5: 00000000000000000000000000011111
/ 6: 00000000000000000000000000111111
/ 7: 00000000000000000000000001111111
/ 8: 00000000000000000000000011111111
/ 9: 00000000000000000000000111111111
/10: 00000000000000000000001111111111
/11: 00000000000000000000011111111111
/12: 00000000000000000000111111111111
/13: 00000000000000000001111111111111
/14: 00000000000000000011111111111111
/15: 00000000000000000111111111111111
/16: 00000000000000001111111111111111
/17: 00000000000000011111111111111111
/18: 00000000000000111111111111111111
/19: 00000000000001111111111111111111
/20: 00000000000011111111111111111111
/21: 00000000000111111111111111111111
/22: 00000000001111111111111111111111
/23: 00000000011111111111111111111111
/24: 00000000111111111111111111111111
/25: 00000001111111111111111111111111
/26: 00000011111111111111111111111111
/27: 00000111111111111111111111111111
/28: 00001111111111111111111111111111
/29: 00011111111111111111111111111111
/30: 00111111111111111111111111111111
/31: 01111111111111111111111111111111
/32: 11111111111111111111111111111111


As you can see, there are 2 problems here. The first problem is that there is no value for a /0 network because python can not bitshift by a negative amount of positions (masksize-1 would be negative).

The second, and more important problem, is that the network mask is the inverse of what it should be!
An easy fix and more intuitive method of calculating the netmask in python, is with the following statement:

mask = int('1' * masksize + '0' * (32-masksize), 2)

This statement just created a string with the correct amount of 1s and 0s, and then converts it to a number with the int function.


Wrong byte order


The next line of code in the scorebot contains:
    ip = struct.unpack('I',socket.inet_aton(ip_txt))[0]

In this code, the textual IP address is converted to numerical format with inet_aton and compacted into an integer with the unpack function

Testing this with the following python code and some example input:


ip="192.168.2.6"
print "%s: %s" % (ip, "{0:032b}".format(struct.unpack('I',socket.inet_aton(ip))[0]))

results in:

192.168.2.6:  00000110000000101010100011000000

for 192.168.2.63:

192.168.2.63: 00111111000000101010100011000000

Although the conversion is the function is a correct conversion for many cases, it is incorrect for usage with a CIDR netmask. Like everything on the internet, things must be ordered in network byte order or big endian. This implies that the byte order must be reversed when working on an Intel processor (like my computer).

Python luckily can do this conversion for you by specifying a '!' in the format specifier on the unpack function, which fixes the problem:


ip="192.168.2.6"
print "%s: %s" % (ip, "{0:032b}".format(struct.unpack('!I',socket.inet_aton(ip))[0]))

resulting in:


192.168.2.6: 11000000101010000000001000000110



Patches


The following patch fixes this problem:

--- ctf_scorebot5.orig/scorebot/standard/submitbot/SubmitWebserver.py 2010-03-17 16:04:59.000000000 +0000
+++ ctf_scorebot5.patched/scorebot/standard/submitbot/SubmitWebserver.py 2012-08-15 11:07:35.702260891 +0000
@@ -20,8 +20,8 @@
 FLAG_MANAGER = None
 def extractNetworkValue(ip_txt,masksize):
- mask = (2L<<masksize-1)-1
- ip = struct.unpack('I',socket.inet_aton(ip_txt))[0] 
+ mask = int('1' * masksize + '0' * (32-masksize), 2)
+ ip = struct.unpack('!I',socket.inet_aton(ip_txt))[0] 
  return ip & mask
 class SubmitHttpHandler(SimpleHTTPRequestHandler):


Of course, you can also use a library like Python's "netaddr", which will do the calculation for you, resulting in much neater code. The following patch does exactly that, plus adds the logic code to support multiple client IP ranges (comma-separated in the config):

--- ctf_scorebot5.orig/scorebot/standard/submitbot/SubmitWebserver.py 2010-03-17 16:04:59.000000000 +0000
+++ ctf_scorebot5/scorebot/standard/submitbot/SubmitWebserver.py 2012-08-14 16:02:49.526286845 +0000
@@ -5,6 +5,7 @@
 import os
 import cgi
 import struct 
+import netaddr
 from BaseHTTPServer import HTTPServer
 from SimpleHTTPServer import SimpleHTTPRequestHandler
@@ -16,13 +17,11 @@
 VALID_FILE_REGEX = re.compile("^(/)?\w+\.(html|css|jpg)$")
 FILE_PATH = "/"
-TEAM_DATA = []
+TEAM_DATA = [] #contains a list of (id, cidrs) tuples, with cidrs being an array of textual netblocks belonging to a team
 FLAG_MANAGER = None
-def extractNetworkValue(ip_txt,masksize):
- mask = (2L<<masksize-1)-1
- ip = struct.unpack('I',socket.inet_aton(ip_txt))[0] 
- return ip & mask
+def ipMatchesNetwork(ip, netblock):
+ return netaddr.IPAddress(ip) in netaddr.IPNetwork(netblock)
 class SubmitHttpHandler(SimpleHTTPRequestHandler):
@@ -52,10 +51,11 @@
  def __update(self,hacker_ip,flag_txt):
  hacker_id = -1
- for id, net, cidr_size in TEAM_DATA:
- if(extractNetworkValue(hacker_ip,cidr_size) == net):
- hacker_id = id
- break
+ for id, cidrs in TEAM_DATA:
+ for netblock in cidrs:
+ if(ipMatchesNetwork(hacker_ip, netblock)):
+ hacker_id = id
+ break
  if(hacker_id == -1):
  return "Flag was submitted from an IP not associated with any team!"
@@ -107,9 +107,7 @@
  for team in conf.teams:
  assert(team.id == len(TEAM_DATA))
- cidr_ip,cidr_mask_txt = team.cidr.split("/")
- team_ip = extractNetworkValue(team.host,int(cidr_mask_txt))
- TEAM_DATA.append((team.id,team_ip,int(cidr_mask_txt)))
+ TEAM_DATA.append((team.id,team.cidr.split(",")))
  FLAG_MANAGER = conf.buildFlagManager()