dimanche 20 juin 2010

tutoriel bypass sécurité javascript - solution CTF public nuit du hack 2010 Web App #3

Certains serveurs implémentent des sécurités basées sur javascript. Ces solutions ne sont jamais sûres car l'utilisateur a accès à l'intégralité des procédés de sécurité même s'il est possible d':
- obfusquer le code javascript téléchargé par l'utilisateur,
- implémenter un algorithme de chiffrement en javascript pour identifier coté client l'utilisateur sans que le mot de passe de référence circule en clair sur le réseau.
Ce tutoriel illustre une méthode d'analyse des échanges http et des fonctions javascript utilisées. Il propose une solution à l'épreuve de Capture the flag (CTF) Web App #3 de la nuit du hack 2010.

outils

- python
- module firefox Tamper Data - https://addons.mozilla.org/fr/firefox/addon/966/
- wireshark

l'épreuve


L'épreuve consiste en un formulaire d'authentification:


Jetons un oeil au code source de la page:

< !DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
< html xmlns="http://www.w3.org/1999/xhtml">
< head>
< meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
< title>Restricted Area< /title>
< style>
body {
background-color: #bc0707;
font-family: Verdana;
}

#page {
margin-left: auto;
margin-right: auto;
margin-top: 5%;
width: 40%;
border: 20px solid black;
padding : 50px;
padding-top : 0px;
text-align: center;
background-color: #c8c4c4;
}
< /style>< script type="text/javascript" src="secure.php">< /script>< style>
input {
text-align: center;
border: 3px solid black;
background-color: #c51919;
color: white;
}

.big {
font-size: 1.5em;
}

#result {
display: none;
font-weight: bold;
}

#user_input, #image {
width: 80%;
}
< /style>
< /head>
< body onload="go();">
< div id="page" >
< h1>< span class="big">R< /span>ESTRICTED < span class="big">A< /span>REA< /h1>
< input type="password" id="user_input" value="****************************" onkeypress="validate(event);" />
< br />< br />< span id="result">test< /span>< br />< br />
< img src="stop.png" title="Restricted Area" alt="" id="image" />
< /div>
< /body>
< /html>

Deux fonctions javascript sont appelées:
- go()
- validate()

Au chargement de la page, la fonction go() est appelée.
< body onload="go();">
Lorsque l'on tape sur une touche dans le champ "input", la fonction validate() est appelée
< input type="password" id="user_input" value="****************************" onkeypress="validate(event);" />

Que font ces deux fonctions? Où est leur code source?

étude

astuce n°1: les fonctions javascript cachées dans le code source de la page

Le code source affiché par Firefox (CTRL-U) ne nous montre pas d'emblée où est le code source de ces deux fonctions. En fait, en faisant une recherche de chaîne de caractères (CTRL-F) sur "script", nous repérons dans le code source un partie du code placée très à droite dans la page.  Ce code renvoie à la page secure.php . Affichons la dans firefox: http://192.168.3.200/secure.php

var request = null;
var ok = true;
var validate = function(ev)
{
var code;
if(!ev) var ev = window.event;
if(ev.keyCode) code = ev.keyCode;
else if(ev.which) code = ev.which;
if( code == 13 )
alert("Loading...");
};

function go()
{
req("secure.php", load, "act=LoadScript");
}

function get()
{
var xhr;
try { xhr = new ActiveXObject("Msxml2.XMLHTTP"); }
catch (e)
{
try { xhr = new ActiveXObject("Microsoft.XMLHTTP"); }
catch (e2)
{
try { xhr = new XMLHttpRequest(); }
catch (e3) { ok = false; }
}
}

return xhr;
}

function req(page, callback, data)
{
request = get();
if ( ok != false )
{
request.onreadystatechange = callback;
request.open("POST", page, true);
request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
request.setRequestHeader("X-Ajax-Powered", "true");
request.send( data );
}
}

function load()
{
if ( ok != false && request.readyState == 4 && request.status == 200)
eval(decrypt(request.responseText));
}

function decrypt( str )
{
var ret = "";
for( var i=0; i< str.length; i++ )
ret += String.fromCharCode(str.charCodeAt(i) ^ 24);
return ret;
}

astuce n°2: la script pour vérifier le password est téléchargé et packé


Nous avons vu que la fonction go() est appelée au chargement de la page.

go() appelle la fonction req()
function go()
{
req("secure.php", load, "act=LoadScript");
}
La fonction req() fait une requête Ajax POST sur le serveur.
function req(page, callback, data)
{
request = get();
if ( ok != false )
{
request.onreadystatechange = callback;
request.open("POST", page, true);
request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
request.setRequestHeader("X-Ajax-Powered", "true");
request.send( data );
}
}
req() définit que la fonction load() sera appelée à chaque changement d'état de la page.
request.onreadystatechange = callback;
rappel: callback est le paramètre passé à req(). Ici, il est égal à load. De plus, req() envoie la requête POST avec "act=LoadScript" dans le body.
req("secure.php", load, "act=LoadScript");

La fonction load() est donc appelée à chaque changement d'état. Quand l'état est égal à 4, c'est à dire quand la réponse est reçue du serveur, elle lance la fonction decrypt() sur la réponse du serveur:
function load()
{
if ( ok != false && request.readyState == 4 && request.status == 200)
eval(decrypt(request.responseText));
}
Cette fonction decrypt() renvoit la chaine entrée avec chacun de ses caractères xoré avec 24. Par exemple si la réponse du serveur est une chaine contenant deux caractères [25,26], la fonction decrypt() renvoie [1,2].
function decrypt( str )
{
var ret = "";
for( var i=0; i< str.length; i++ )
ret += String.fromCharCode(str.charCodeAt(i) ^ 24);
return ret;
}

Or la réponse du serveur est justement le code javascript qui traite le password entré. Donc le script qui permet de contrôler les données entrées par l'utilisateur est packé.

astuce n°3: la fonction validate est dynamiquement remplacée par un autre script

récupérons la réponse du serveur

avec Wireshark:

Copions le contenu applicatif de la réponse dans un fichier:
Clic sur la réponse à la première requête POST /secure.php (en grisé ci-dessus), puis clic droit sur "Line-based text data: ...", choisir "Export Selected Packets Bytes". Enregistrer dans un fichier "dumpjavax"

récupérons également la seconde réponse du serveur

Cette réponse servira plus tard...

dépackons la réponse

Nous utilisons un petit script python:
inputfile = open('dumpjavax','r')
outputfile = open('decryptjavax.js','w')
string = inputfile.read()

i = 0
while i< len(string):
  outputfile.write( chr( ord( string[i] ) ^ 24 ) )
  i = i+1
outputfile.close()
inputfile.close()
Nous obtenons le script suivant:
validate = function(ev)
{
     function check()
{
if ( ok != false && request.readyState  == 4 && request.status  == 200)
    result(passCmp( request.responseText ));
}
             
function crypt( str )
{
while( str.length%8 != 0 )
    str += String.fromCharCode(255);

var len = str.length;
var cipher_array = new Array();
var plain_array = new Array();
for( var i=0; i< len; i++ )
{
    cipher_array[i] = 0;
    plain_array[i] = str.charCodeAt(i);
}

for( var i=0; i< len; i++ )
    for( var j=0; j< 8; j++ )
        cipher_array[j+(Math.floor(i/8)*8)] |= (((plain_array[i]>>j)&0x01)< < (i%8));

var cipher = "";
for( var i=0; i< len; i++ )
    cipher += String.fromCharCode(cipher_array[i]);
    
return cipher;
}
                
                function passCmp( pass )
                {
return ( crypt(document.getElementById("user_input").value) == pass );
                }
                
                function clear()
                {
document.getElementById("result").style.display = "none";
document.getElementById("result").innerHTML = "";
                }
                
                function win()
                {
document.location.href = document.getElementById("user_input").value + ".html";
                }
                
                function result( res )
                {
if ( res )
{
    document.getElementById("result").style.color = "green";    
    document.getElementById("result").innerHTML = "Access Granted !";
    setTimeout(win, 2000);
}
else
{
    document.getElementById("result").style.color = "red";    
    document.getElementById("result").innerHTML = "Access Denied !";
    setTimeout(clear, 2000);
}
                
document.getElementById("result").style.display = "block";
                }
                
                var code;
                if(!ev) var ev = window.event;
                if(ev.keyCode) code = ev.keyCode;
                else if(ev.which) code = ev.which;
                if( code == 13 )
req("secure.php", check, "act=GetPass");
            };
           
Nous remarquons que la variable validate est redéfinie. Rappel: cette variable a été définie comme une fonction dans le code source de la page principale:
< input type="password" id="user_input" value="****************************" onkeypress="validate(event);" />

astuce n°4: la nouvelle fonction validate importe le javascript de traitement du password


La nouvelle fonction validate détecte l'appui sur la touche ENTER (CR = 13 en ASCII) pour IExplorer (window.event.keyCode) ou pour Netscape (window.event.which). Puis elle exécute la fonction req().
var code;
if(!ev) var ev = window.event;
if(ev.keyCode) code = ev.keyCode;
else if(ev.which) code = ev.which;
if( code == 13 )
req("secure.php", check, "act=GetPass");
La fonction req() fait une requête POST sur le serveur avec "act=GetPass" dans le body puis exécute la fonction check() à l'arrivée de la réponse du serveur.
function req(page, callback, data)
{
request = get();
if ( ok != false )
{
request.onreadystatechange = callback;
request.open("POST", page, true);
request.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
request.setRequestHeader("X-Ajax-Powered", "true");
request.send( data );
}
}
check() récupère la réponse du serveur et lance les fonctions passCmp() et result()
function check()
{
if ( ok != false && request.readyState  == 4 && request.status  == 200)
result(passCmp( request.responseText ));
}
La fonction result() se contente d'afficher "Access Granted!" en vert quand le mot de passe correspond.
function result( res )
{
if ( res )
{
document.getElementById("result").style.color = "green";    
document.getElementById("result").innerHTML = "Access Granted !";
setTimeout(win, 2000);
}

else
{
document.getElementById("result").style.color = "red";    
document.getElementById("result").innerHTML = "Access Denied !";
setTimeout(clear, 2000);
}

document.getElementById("result").style.display = "block";
}

astuce n°5: le chiffré du password de référence est téléchargé depuis le serveur. Le mot de passe entré par l'utilisateur est chiffré puis comparé.


passCmp() appelle la fonction crypt sur les données entrées par l'utilisateur puis les compare avec les données renvoyées par le serveur. C'est donc le chiffré du mot de passe de référence qui est reçu du serveur.
function passCmp( pass )
{
return ( crypt(document.getElementById("user_input").value) == pass );
}

astuce n°6: le chiffrement est une simple permutation de bits

l'algorithme de chiffrement

Nous allons voir que l'algorithme permute, pour chaque mot de 8 caractères du clair, le [j]ème bit du [i]ème caractère du clair, avec le [i%8]ème bit du [j]ème caractère.

Donc l'algorithme est symétrique!

analyse de l'algorithme de chiffrement

Voyons l'algorithme de chiffrement. La fonction crypt() prend le mot de passe entré par l'utilisateur en paramètre.
function crypt( str )
{
while( str.length%8 != 0 )
   str += String.fromCharCode(255);
    
var len = str.length;
var cipher_array = new Array();
var plain_array = new Array();
for( var i=0; i< len; i++ )
{
    cipher_array[i] = 0;
    plain_array[i] = str.charCodeAt(i);
}

for( var i=0; i< len; i++ )
    for( var j=0; j< 8; j++ )
        cipher_array[j+(Math.floor(i/8)*8)] |= (((plain_array[i]>>j)&0x01)< < (i%8));

var cipher = "";
for( var i=0; i< len; i++ )
    cipher += String.fromCharCode(cipher_array[i]);
    
return cipher;
                }
La fonction commence par un padding pour qu'elle soit constituée de mots de 8 caractères.
while( str.length%8 != 0 )
   str += String.fromCharCode(255);
Remarque: rappel des opérateurs en javascript (http://commentcamarche.net/contents/javascript/jsop.php3)

L'algorithme est le suivant:
for( var i=0; i< len; i++ )
    for( var j=0; j< 8; j++ )
        cipher_array[j+(Math.floor(i/8)*8)] |= (((plain_array[i]>>j)&0x01)< < (i%8));
Rq: On divise le clair en mots de 8 caractères. En parcourant le mdp clair, on sera dans le [m]ème mot. < => Math.floor(i/8)

Pour chaque caractère du mot de passe clair ( [i]eme caractere, on est alors dans [i%8]ème caractère du [m]ème mot )
   Pour chaque bit du caractère ( [j]ème bit du [i]ème caractère ):
       extraire le bit    < =>    ((plain_array[i]>>j)&0x01) 
       le placer au [i%8]ème bit d'un caractère [c]   < =>    < < (i%8)  
       ce caractère [c] est XORé avec le [j]ème caractère du mot en cours ( [m] )  < => j+(Math.floor(i/8)*8)

par exemple:

Voici une table ASCII complète: http://www.table-ascii.com/

Supposons que le clair est "@             @". Il est constitué de deux mots:
@              SP            SP             SP             SP             SP             SP             SP            
01000000 00100000 00100000 00100000 00100000 00100000 00100000 00100000

SP             SP            SP             SP             SP             SP             @                      
00100000 00100000 00100000 00100000 00100000 00100000 01000000
Déroulons l'algorithme de chiffrement.

Le clair est paddé avec 255 pour compléter les mots à 8 caractères:

@              SP            SP             SP             SP             SP             SP             SP            
01000000 00100000 00100000 00100000 00100000 00100000 00100000 00100000

SP             SP            SP             SP             SP             SP             @              0xFF                  
00100000 00100000 00100000 00100000 00100000 00100000 01000000 11111111
premier caractère (i=0): @ = 01000000
le 2ème bit du caractère (j=1) est placé au 1er bit (i=0) du 2ème caractère (j=2) du 1er mot (i/8)

deuxième caractère (i=1): SP = 00100000
le 3ème bit du caractère (j=2) est placé au 2ème bit (i=1) du 3ème caractère (j=2) du 1er mot (i/8)

troisième caractère (i=2): SP = 00100000
le 3ème bit du caractère (j=2) est placé au 3ème bit (i=2) du 3ème caractère (j=2) du 1er mot (i/8)
       
quatrième caractère (i=3): SP = 00100000
le 3ème bit du caractère (j=2) est placé au 4ème bit (i=3) du 3ème caractère (j=2) du 1er mot (i/8)

cinquième caractère (i=4): SP = 00100000
le 3ème bit du caractère (j=2) est placé au 5ème bit (i=4) du 3ème caractère (j=2) du 1er mot (i/8)

sixième caractère (i=5): SP = 00100000
le 3ème bit du caractère (j=2) est placé au 6ème bit (i=5) du 3ème caractère (j=2) du 1er mot (i/8)

septième caractère (i=6): SP = 00100000
le 3ème bit du caractère (j=2) est placé au 7ème bit (i=6) du 3ème caractère (j=2) du 1er mot (i/8)

huitième caractère (i=7): SP = 00100000
le 3ème bit du caractère (j=2) est placé au 8ème bit (i=7) du 3ème caractère (j=2) du 1er mot (i/8)

neuvième caractère (premier caractère du deuxième mot i=8): SP = 00100000
le 3ème bit du caractère (j=2) est placé au 1er bit (i=8) du 3ème caractère (j=2) du 2ème mot (i/8)

etc.

astuce n°7 Les données reçues en Ajax sont codées en UTF8


L'Ajax utilisant l'UTF8, il est nécessaire de corriger l'encodage des données à l'entrée de notre script.


Solution

Voici un script python qui trouve le résultat.

import math

inputfile = open('mdp_chiffre','r')
outputfile = open('mdp_clair','w')

# correction de l'encodage UTF8 de javax
cipher_utf8 = inputfile.read()

#initialisation des variables
cipher = unicode(cipher_utf8, "utf-8")
plain = ''
cipher_array = []
plain_array = []

# padding
while len(cipher)%8 != 0:
  cipher += chr(255)

# string to array
for c in cipher:
  cipher_array.append(ord(c))
  plain_array.append(0)

# algorithme de chiffrement
for i in range(len(cipher_array)):
  for j in range(8):
    plain_array[ j + int(math.floor(i/8)*8) ] |= ( (cipher_array[i]>>j) & 0x01 )<<(i%8)

# array to string
for i in range(len(plain_array)):
  plain += chr(plain_array[i])

# enregistrement du clair dans un fichier
# attention, un simple "print" sur la console ne marche pas à cause de l'encodage de la console
for i in range(len(plain_array)):
  outputfile.write(chr(plain_array[i]))

#fermeture des fichiers
inputfile.close()
outputfile.close()

1 commentaire:

  1. Toujours aussi intéressant et clair.
    Merci pour le temps passé.

    RépondreSupprimer