CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: scikit-learn のハイパーパラメータを GridSearchCV で最適化する

機械学習のアルゴリズムにおいて、人が調整する必要のあるパラメータのことをハイパーパラメータと呼ぶ。 これは自動では決められないので、色々な値を試したりして汎化性能が高くなるものを選ばなきゃいけない。 今回はハイパーパラメータを決めるのに scikit-learn に実装されている GridSearchCV という機能を使ってみる。 これを使うと、あらかじめいくつか候補を与えることで、その中から汎化性能が高くなるものを選んでくれる。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29
$ python --version
Python 3.6.2

下準備

まずは必要なパッケージをインストールしておく。

$ pip install scikit-learn numpy scipy

サンプルコード

次のサンプルコードでは GridSearchCV を使って DecisionTreeClassifier の最適なハイパーパラメータを探索している。 DecisionTreeClassifier というのは決定木のアルゴリズムを使った分類器を指す。 データセットには、みんな大好きアイリス (あやめ) データセットを使った。

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

from sklearn import datasets
from sklearn.tree import DecisionTreeClassifier
from sklearn.model_selection import GridSearchCV


def main():
    # アイリスデータセットを読み込む
    dataset = datasets.load_iris()

    # 教師データとラベルデータを取り出す
    features = dataset.data
    targets = dataset.target

    # 優れたハイパーパラメータを見つけたいモデル
    clf = DecisionTreeClassifier()

    # 試行するパラメータを羅列する
    params = {
        'max_depth': list(range(1, 20)),
        'criterion': ['gini', 'entropy'],
    }

    grid_search = GridSearchCV(clf,  # 分類器を渡す
                               param_grid=params,  # 試行してほしいパラメータを渡す
                               cv=10,  # 10-Fold CV で汎化性能を調べる
                               )

    # グリッドサーチで優れたハイパーパラメータを探す
    grid_search.fit(features, targets)

    print(grid_search.best_score_)  # 最も良かったスコア
    print(grid_search.best_params_)  # 上記を記録したパラメータの組み合わせ


if __name__ == '__main__':
    main()

説明はコメントでしてるけど、基本的には GridSearchCV に分類器とハイパーパラメータの候補を渡す。 その上でデータセットに対して学習 (fit) させると、最も汎化性能の高い組み合わせがメンバの best_params_ に入る。

実行してみる

上記のサンプルコードを適当な名前で保存したら実行してみよう。 すると、候補の中で最も高い汎化性能が得られるものは精度が 0.96 であることが分かる。 そして、そのときのパラメータは criterionginimax_depth3 ということが分かった。

$ python gridsearch.py
0.96
{'criterion': 'gini', 'max_depth': 3}

まとめ

scikit-learn でハイパーパラメータの調整をするときは GridSearchCV を使うと便利。

オープンソースの脆弱性スキャナ OpenVAS を使ってみる

今回はオープンソースの脆弱性スキャナである OpenVAS を使ってみることにする。 脆弱性スキャナというのは、ホストに既知の脆弱性が含まれないかどうかを自動でスキャンしてくれるツール。

注意: 脆弱性スキャンはポートスキャンやペネトレーションを含むため外部のサーバには実行しないこと

使った環境は次の通り。OS は Ubuntu 16.04 LTS にした。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 16.04.3 LTS
Release:    16.04
Codename:   xenial
$ uname -r
4.4.0-89-generic

セキュリティ用途だと Kali Linux を使うことも多いみたい。

インストール

Ubuntu で OpenVAS を使うには以下の PPA を追加する。 追加したらリポジトリの状態を更新しておく。

$ sudo add-apt-repository ppa:mrazavi/openvas
$ sudo apt-get update

現行の OpenVAS バージョン 9 をインストールするときはパッケージ名として openvas9 を指定する。

$ sudo apt-get -y install openvas9

ちなみにインストールの過程で脆弱性をテストするための情報を更新するために rsync プロトコルを使う。 ファイアウォールで阻まれる恐れもあるので TCP の 873 ポートで通信できるか、あらかじめ確認しておこう。

インストールできたら脆弱性のテストに使うデータを更新していく。 過去に見つかった脆弱性とテスト方法の情報をダウンロードしてくるので、これには結構長い時間がかかる。

$ sudo apt-get -y install sqlite3
$ sudo greenbone-nvt-sync
$ sudo greenbone-scapdata-sync
$ sudo greenbone-certdata-sync

更新が終わったら OpenVAS のサービスを起動する。

$ sudo systemctl start openvas-scanner
$ sudo systemctl enable openvas-scanner
$ sudo systemctl start openvas-manager
$ sudo systemctl enable openvas-manager

次に OpenVAS のデーモンの設定を初期化しておく。

$ sudo openvasmd --rebuild --progress

最後に、脆弱性のテストに使う admin ユーザのパスワードを更新しておこう。

$ sudo openvasmd --user=admin --new-password=admin_new_password

これは後述する WebUI などを操作するときに用いる。

脆弱性をスキャンする

以上で OpenVAS を使う準備が整った。

TCP の 4000 番ポートで GSA (Greebone Security Assistant) という WebUI が使えるようになっている。

$ ss -tl | grep 4000
LISTEN     0      32        :::4000                    :::*                    

適当なブラウザで GSA を開こう。

$ sudo apt-get -y install firefox
$ firefox https://localhost:4000

自己署名証明書の警告が出るけど、ひとまず無視して開くと次のようなログイン画面が現れる。

f:id:momijiame:20170809002938p:plain

先ほどの admin ユーザと設定したパスワードでログインすると、次のようなダッシュボードに遷移する。

f:id:momijiame:20170809003038p:plain

脆弱性スキャンを実行するために Tasks の画面に移動する。

f:id:momijiame:20170809003055p:plain

Task Wizard を開く。

f:id:momijiame:20170809003233p:plain

ここにスキャンしたいホストを入力する。 今回は自分自身 (ローカルホスト) に対して実行してみよう。

f:id:momijiame:20170809003300p:plain

あとは OpenVAS がポートスキャンやらペネトレーションテストを粛々と実行してくれるので気長に待つ。

しばらくすると、次のように Result の画面で実行結果が見られるようになる。

f:id:momijiame:20170809003342p:plain

このホストの場合、弱いサイファースイートを受け付けるようになっているのが最も重要度の高い指摘事項だった。 Severity (重要度) は 10 段階中の 5 なので真ん中くらい。

f:id:momijiame:20170809003450p:plain

こんな感じで OpenVAS はホストの設定にどんな問題があるかを自動で調べてくれる。

まとめ

今回はオープンソースの脆弱性スキャナである OpenVAS を使ってみた。 当たり前だけど、脆弱性スキャナはこれさえ使っていれば OK というような代物ではない。 あらかじめ決められたルールで調べているに過ぎないので見つけられないものは数多くある。 しかし、あきらかに問題のある設定を自動で見つけ出してくれるというのは心強いんじゃないだろうか。

コンピュータネットワークセキュリティ

コンピュータネットワークセキュリティ

Nmap を使ってシステムに不要なポートが開いていないか調べる

今回はセキュリティの世界では有名な Nmap というツールを使ってみる。 このツールは、ポートスキャンや簡易な脆弱性スキャナーとして利用される。 例えば不要なポートが外部からアクセスできるようになっていないかを調べることは重要なポイントだ。 Nmap は高機能なので、とても全ては紹介できないから今回は基本的なポートスキャンについて書いてみる。

注意: ポートスキャンは攻撃と見なされるので外部のサーバには実行しないこと

今回使った環境は次の通り。

$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description:    Ubuntu 16.04.3 LTS
Release:    16.04
Codename:   xenial
$ uname -r
4.4.0-89-generic

インストール

Ubuntu であればリポジトリからインストールできる。

$ sudo apt-get -y install nmap

TCP ポートをスキャンする

試しに何もオプションを付けずに自身のループバックアドレスに対して実行してみよう。 これだけで主要な TCP のポートが開いているかどうかを一気に調べることができる。

$ sudo nmap localhost

Starting Nmap 7.01 ( https://nmap.org ) at 2017-08-07 12:32 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.000045s latency).
Not shown: 999 closed ports
PORT   STATE SERVICE
22/tcp open  ssh

Nmap done: 1 IP address (1 host up) scanned in 0.04 seconds

上記のスキャンで SSH 用の TCP:22 ポートが開いていることが分かった。

もちろんローカルホストで何処のポートが開いているか調べるだけなら ss なり netstat コマンドを使うこともできる。

$ ss -tl
State      Recv-Q Send-Q Local Address:Port                 Peer Address:Port                
LISTEN     0      128        *:ssh                      *:*                    
LISTEN     0      128       :::ssh                     :::*                    

とはいえ、ここで Listen しているポートがインターネットなど外部からアクセスできるかどうかは、また別問題。 そうした意味では、自前のシステムに外部からスキャンをかけてチェックするのには意味がある。

別のポートも開けてみる

試しに別のポートも開けて実行結果が変わることを確認してみよう。

例として Apache httpd をインストールしてみる。

$ sudo apt-get -y install apache2

ポートスキャンを実行する。

$ sudo nmap localhost

Starting Nmap 7.01 ( https://nmap.org ) at 2017-08-07 12:33 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.000043s latency).
Not shown: 998 closed ports
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 0.04 seconds

先ほどの結果に加えて TCP:80 (HTTP) も開いたことが分かる。

スキャンのテクニックについて

Nmap には、テクニックと呼ばれるスキャンのやり方がいくつも用意されている。 スキャンをするとき、どういった通信が発生しているのかを確認してみることにしよう。 ちなみに、自前でテクニックを書いて追加することも可能らしい。

通信をキャプチャするために tcpdump をインストールする。

$ sudo apt-get -y install tcpdump

TCP SYN scan

管理者権限をつけて実行したときのデフォルトは TCP SYN scan と呼ばれるテクニックになっている。 これは -sS オプションを付けたときと同じ。

$ sudo nmap -sS localhost

Starting Nmap 7.01 ( https://nmap.org ) at 2017-08-07 12:38 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.0000040s latency).
Not shown: 998 closed ports
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 1.63 seconds

通信内容を確認すると TCP の SYN フラグのついたパケットを送信している。 それに対して相手が SYN/ACK を返すとポートを Open と見なしているのだろう。 ただし、実際にスリーウェイハンドシェイクは完了させず RST フラグのついたパケットを送って通信を切断している。 RST は Linux Kernel が送っているものかな?

$ sudo tcpdump -i lo "port 80"
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes
12:38:39.661017 IP localhost.36923 > localhost.http: Flags [S], seq 4178950553, win 1024, options [mss 1460], length 0
12:38:39.661036 IP localhost.http > localhost.36923: Flags [S.], seq 3106868738, ack 4178950554, win 43690, options [mss 65495], length 0
12:38:39.661042 IP localhost.36923 > localhost.http: Flags [R], seq 4178950554, win 0, length 0

TCP のセッションを実際には成立させないので、おそらく秘匿性にも優れていると思われる。

TCP connect scan

管理者権限をつけずに実行したり -sT オプションを指定したときに使われるのが TCP connect scan というテクニックになる。

$ sudo nmap -sT localhost

Starting Nmap 7.01 ( https://nmap.org ) at 2017-08-07 12:39 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.000041s latency).
Not shown: 998 closed ports
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 0.04 seconds

先ほどと同じように通信内容をキャプチャしてみよう。 すると、今度は SYN > SYN/ACK > ACK というスリーウェイハンドシェイクが完了していることが分かる。 つまり TCP のセッションを実際に成立させているということだ。 おそらく、これはソケットのインターフェースを使って実現しているんだろう。

$ sudo tcpdump -i lo "port 80"
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on lo, link-type EN10MB (Ethernet), capture size 262144 bytes
12:39:23.661866 IP localhost.55316 > localhost.http: Flags [S], seq 55539266, win 43690, options [mss 65495,sackOK,TS val 189029 ecr 0,nop,wscale 7], length 0
12:39:23.661872 IP localhost.http > localhost.55316: Flags [S.], seq 3737402388, ack 55539267, win 43690, options [mss 65495,sackOK,TS val 189029 ecr 189029,nop,wscale 7], length 0
12:39:23.661878 IP localhost.55316 > localhost.http: Flags [.], ack 1, win 342, options [nop,nop,TS val 189029 ecr 189029], length 0

TCP のセッションを実際に成立させるので 、おそらく TCP SYN scan に比べると秘匿性は劣ることだろう。

その他、ポートスキャンのテクニックについては以下のページにまとまっている。 もちろんテクニックを選べば UDP のポートもスキャンもできる。

Port Scanning Techniques

Version Detection

これはテクニックとは異なるけど -sV オプションをつけると、オープンしているポートで動作しているアプリケーションについての情報も調べることができる。

$ sudo nmap -sV localhost

Starting Nmap 7.01 ( https://nmap.org ) at 2017-08-07 12:40 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.0000070s latency).
Not shown: 998 closed ports
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.2p2 Ubuntu 4ubuntu2.2 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    Apache httpd 2.4.18 ((Ubuntu))
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 8.40 seconds

OS のバージョンとか、さらに詳しい情報まで得たいときは -A オプションが使える。

$ sudo nmap -A localhost

Starting Nmap 7.01 ( https://nmap.org ) at 2017-08-07 12:45 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.0000090s latency).
Not shown: 998 closed ports
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 7.2p2 Ubuntu 4ubuntu2.2 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   2048 e6:bb:3f:fc:95:3f:27:ab:68:45:c6:c1:90:fa:c2:11 (RSA)
|_  256 f7:cc:ba:13:46:ef:c9:e2:74:94:2f:bf:59:ef:19:c6 (ECDSA)
80/tcp open  http    Apache httpd 2.4.18 ((Ubuntu))
|_http-server-header: Apache/2.4.18 (Ubuntu)
|_http-title: Apache2 Ubuntu Default Page: It works
Device type: general purpose
Running: Linux 3.X
OS CPE: cpe:/o:linux:linux_kernel:3
OS details: Linux 3.12 - 3.19, Linux 3.8 - 3.19
Network Distance: 0 hops
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 10.89 seconds

まとめ

今回はセキュリティの世界で有名な Nmap というツールを使ってポートスキャンをする方法について書いた。 Nmap でポートスキャンすることで、システムの不要なポートが外部に公開されていないかを確認できる。 繰り返しになるけどポートスキャンといえど攻撃と見なされるので外部のサーバには実行しないように。

コンピュータネットワークセキュリティ

コンピュータネットワークセキュリティ

sqlmap を使って SQL インジェクションの脆弱性を検証する

sqlmap は SQL インジェクションに特化したオープンソースのペネトレーションテストツール。 これを使うと Web アプリケーションの特定のパラメータに SQL インジェクションの脆弱性があるか否かを確認しやすい。

注意: 外部のサーバに使うことは攻撃となるので絶対にしないように。

今回使った環境は次の通り。

$ sw_vers    
ProductName:    Mac OS X
ProductVersion: 10.12.6
BuildVersion:   16G29
$ sqlmap --version
1.1.8#stable
$ python --version
Python 2.7.10

インストール

sqlmap は Homebrew でインストールできる。

$ brew install sqlmap

これで sqlmap コマンドが使えるようになる。

$ sqlmap --version
1.1.8#stable

ちなみに sqlmap は Python で書かれているけど Python 3 では動かない。

$ python --version
Python 3.6.2
$ sqlmap   
[CRITICAL] incompatible Python version detected ('3.6.2'). For successfully running sqlmap you'll have to use version 2.6.x or 2.7.x (visit 'http://www.python.org/download/')

作者の方針的に Python 3 対応をする予定はないようだ。

Port code to Python 3 · Issue #93 · sqlmapproject/sqlmap · GitHub

ターゲット

今回はツールの検証のために意図的に SQL インジェクションの脆弱性を仕込んだ Web アプリケーションを用意してみた。 これは Python の Flask で書いてある。 データベースには組み込みで使える SQLite3 を使った。

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

import sqlite3

from flask import Flask
from flask import request
from flask import abort
from flask import jsonify

app = Flask(__name__)


@app.route('/users')
def get():
    # クエリパラメータから 'name' の値を取得する
    name = request.args.get('name')
    # ひとまずクエリパラメータは必須ということで
    if name is None:
        abort(400)

    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    try:
        # XXX: クエリの組み立てにプレースホルダーを使っておらず SQL インジェクションが発生しうる
        query = 'SELECT * FROM users where name like \'{name}\''.format(name=name)  # noqa
        cursor.execute(query)

        # 結果を取得して表示する
        result = cursor.fetchall()
    finally:
        # 後片付け
        cursor.close()
        conn.close()

    # レスポンスの組み立て
    response_body = {
        'users': [
            {
                'name': name,
                'age': age,
            }
            for name, age in result
        ]
    }
    response = jsonify(response_body)
    response.status_code = 200
    response.headers['Content-Type'] = 'application/json'
    return response


def initialize_db():
    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    try:
        # 既存の内容があれば消す
        cursor.execute('DROP TABLE IF EXISTS users')
        # テーブルのスキーマを作る
        cursor.execute('CREATE TABLE users (name TEXT, age INTEGER)')
        # ダミーデータを追加する
        cursor.execute('INSERT INTO users VALUES (?, ?)', ('Alice', 20))
        cursor.execute('INSERT INTO users VALUES (?, ?)', ('Bob', 25))
        cursor.execute('INSERT INTO users VALUES (?, ?)', ('Carol', 30))
        conn.commit()
    finally:
        # 後片付け
        cursor.close()
        conn.close()


def main():
    # データベースを初期化する
    initialize_db()
    # アプリケーションを起動する
    app.run()


if __name__ == '__main__':
    main()

上記のアプリケーションは SQL の実行にプレースホルダを使っていないため SQL インジェクションの脆弱性がある。

使い方

動かすには Flask が必要なのでインストールする。

$ pip install flask

あとは先ほどのファイルを適当な名前で保存して実行するだけ。

$ python queryparam.py 
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

上手くいけば次のように URL のクエリパラメータで既存のユーザを検索できるアプリケーションが動く。 ただ、このパラメータ name には SQL インジェクションの脆弱性がある。

$ curl 'http://localhost:5000/users?name=Alice'
{
  "users": [
    {
      "age": 20,
      "name": "Alice"
    }
  ]
}

sqlmap でペネトレーションテストする

それでは sqlmap を使って先ほどのアプリケーションをペネトレーションテストしてみよう。

クエリパラメータであれば -u オプションを使って URL をそのまま指定するだけでテストできる。 --dump オプションは脆弱性があったときにデータベースの内容をダンプすることを意味する。

$ sqlmap -u 'http://localhost:5000/users?name=Alice' --dump

いくつか質問に答えていって、脆弱性が実際に見つかると次のような出力が得られる。

$ sqlmap -u 'http://localhost:5000/users?name=Alice' --dump
...(snip)...
GET parameter 'name' is vulnerable. Do you want to keep testing the others (if any)? [y/N]

さらに進めると users テーブルの内容が表示される。

$ sqlmap -u 'http://localhost:5000/users?name=Alice' --dump
...(snip)...
GET parameter 'name' is vulnerable. Do you want to keep testing the others (if any)? [y/N]
...(snip)...
Database: SQLite_masterdb
Table: users
[3 entries]
+-----+-------+
| age | name  |
+-----+-------+
| 20  | Alice |
| 25  | Bob   |
| 30  | Carol |
+-----+-------+

仮にこれが実際の攻撃ならデータベースの内容を盗み出されていたことになる。

フォームタグ内のパラメータをテストする

さっきはクエリパラメータだったけど、次はフォームタグで送信される内容を想定してみよう。 これはようするに Content-Typeapplication/x-www-form-urlencoded されるもの。

サンプルコードは次の通り。 先ほどと同じようにユーザを検索するというもの。

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

import sqlite3

from flask import Flask
from flask import request
from flask import abort
from flask import jsonify

app = Flask(__name__)


@app.route('/users', methods=['POST'])
def post():
    # フォームパラメータから 'name' の値を取得する
    name = request.form.get('name')
    # ひとまずクエリパラメータは必須ということで
    if name is None:
        abort(400)

    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    try:
        # XXX: クエリの組み立てにプレースホルダーを使っておらず SQL インジェクションが発生しうる
        query = 'SELECT * FROM users where name like \'{name}\''.format(name=name)  # noqa
        cursor.execute(query)

        # 結果を取得して表示する
        result = cursor.fetchall()
    finally:
        # 後片付け
        cursor.close()
        conn.close()

    # レスポンスの組み立て
    response_body = {
        'users': [
            {
                'name': name,
                'age': age,
            }
            for name, age in result
        ]
    }
    response = jsonify(response_body)
    response.status_code = 200
    response.headers['Content-Type'] = 'application/json'
    return response


def initialize_db():
    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    try:
        # 既存の内容があれば消す
        cursor.execute('DROP TABLE IF EXISTS users')
        # テーブルのスキーマを作る
        cursor.execute('CREATE TABLE users (name TEXT, age INTEGER)')
        # ダミーデータを追加する
        cursor.execute('INSERT INTO users VALUES (?, ?)', ('Alice', 20))
        cursor.execute('INSERT INTO users VALUES (?, ?)', ('Bob', 25))
        cursor.execute('INSERT INTO users VALUES (?, ?)', ('Carol', 30))
        conn.commit()
    finally:
        # 後片付け
        cursor.close()
        conn.close()


def main():
    # データベースを初期化する
    initialize_db()
    # アプリケーションを起動する
    app.run()


if __name__ == '__main__':
    main()

先ほどと同じように適当な名前をつけて実行する。

$ python contentbody.py 
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

curl コマンドからは次のようにして呼び出せる。

$ curl -X POST -d 'name=Alice' http://localhost:5000/users
{
  "users": [
    {
      "age": 20,
      "name": "Alice"
    }
  ]
}

sqlmap コマンドで上記のパラメータをテストするときは --data オプションを使う。

$ sqlmap -u 'http://localhost:5000/users' --data 'name=Alice' --dump

実行すると次のようにデータベースの内容がダンプして得られる。

$ sqlmap -u 'http://localhost:5000/users' --data 'name=Alice' --dump
...(snip)...
Database: SQLite_masterdb
Table: users
[3 entries]
+-----+-------+
| age | name  |
+-----+-------+
| 20  | Alice |
| 25  | Bob   |
| 30  | Carol |
+-----+-------+

JSON を使った WebAPI をテストする

次は Content-Typeapplication/json な WebAPI をテストするときについて。

サンプルコードは次の通り。 WebAPI なのに POST でユーザを検索するっていう、ちょっと不自然なものになっちゃってる。 これは INSERT とか UPDATE を使った SQL インジェクションは難しくて sqlmap がそれに対応していないのが理由。

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

import sqlite3

from flask import Flask
from flask import request
from flask import abort
from flask import jsonify

app = Flask(__name__)


@app.route('/users', methods=['POST'])
def post():
    # クエリパラメータから 'name' の値を取得する
    name = request.json.get('name')
    # ひとまずクエリパラメータは必須ということで
    if name is None:
        abort(400)

    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    try:
        # XXX: クエリの組み立てにプレースホルダーを使っておらず SQL インジェクションが発生しうる
        query = 'SELECT * FROM users where name like \'{name}\''.format(name=name)  # noqa
        cursor.execute(query)

        # 結果を取得して表示する
        result = cursor.fetchall()
    finally:
        # 後片付け
        cursor.close()
        conn.close()

    # レスポンスの組み立て
    response_body = {
        'users': [
            {
                'name': name,
                'age': age,
            }
            for name, age in result
        ]
    }
    response = jsonify(response_body)
    response.status_code = 200
    response.headers['Content-Type'] = 'application/json'
    return response


def initialize_db():
    conn = sqlite3.connect('users.db')
    cursor = conn.cursor()

    try:
        # 既存の内容があれば消す
        cursor.execute('DROP TABLE IF EXISTS users')
        # テーブルのスキーマを作る
        cursor.execute('CREATE TABLE users (name TEXT, age INTEGER)')
        # ダミーデータを追加する
        cursor.execute('INSERT INTO users VALUES (?, ?)', ('Alice', 20))
        cursor.execute('INSERT INTO users VALUES (?, ?)', ('Bob', 25))
        cursor.execute('INSERT INTO users VALUES (?, ?)', ('Carol', 30))
        conn.commit()
    finally:
        # 後片付け
        cursor.close()
        conn.close()


def main():
    # データベースを初期化する
    initialize_db()
    # アプリケーションを起動する
    app.run()


if __name__ == '__main__':
    main()

上記のアプリケーションは curl コマンドからだと次のように呼び出せる。

$ curl -X POST -H "Content-Type: application/json" -d '{"name": "Alice"}' http://localhost:5000/users
{
  "users": [
    {
      "age": 20,
      "name": "Alice"
    }
  ]
}

上記を sqlmap でテストするときは、まず Content-Body に JSON を含んだリクエスト内容をファイルとして用意しておく。

$ cat << 'EOF' > json-request.txt
POST /users HTTP/1.1
Host: localhost:5000
Content-Type: application/json

{"name": "Alice"}
EOF

sqlmap コマンドでは、上記のファイルを -r オプションで指定する。

$ sqlmap -r json-request.txt --dump

すると JSON 使ってる?って sqlmap が確認してくるので Y する。

$ sqlmap -r json-request.txt --dump

JSON data found in POST data. Do you want to process it? [Y/n/q]

あとはこれまでと同じようにやっていくとデータベースの内容が出力された。

...(snip)...
Database: SQLite_masterdb
Table: users
[3 entries]
+-----+-------+
| age | name  |
+-----+-------+
| 20  | Alice |
| 25  | Bob   |
| 30  | Carol |
+-----+-------+

まとめ

今回は sqlmap を使った SQL インジェクションのペネトレーションテストをするやり方について書いた。 正直なところ SQL インジェクションくらい初歩的な脆弱性ならコードを確認した方が早いのではとも思ってしまう。 しかし、脆弱性の有無を調べるにはコードを確認するような静的解析以外にも、今回やったような動的解析もまた一つのやり方といえるだろう。 繰り返しになるけど sqlmap を外部のサーバに使うのは攻撃になるので絶対にやらないこと。

コンピュータネットワークセキュリティ

コンピュータネットワークセキュリティ

SQL: CASE 句を使って縦持ちのデータを横持ちに変換する

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.12.5
BuildVersion:   16F73
$ mysql --version
mysql  Ver 15.1 Distrib 10.2.6-MariaDB, for osx10.12 (x86_64) using readline 5.1

サンプルのテーブルとレコードを用意する

まずはサンプルとして次のようなテーブルを用意する。 これは色々なアイテムの購入履歴を表している。 購入された時刻とアイテムのコード、そして数量が記録される。

> CREATE TABLE orders(
    ->   datetime DATETIME,
    ->   code VARCHAR(255) NOT NULL,
    ->   amount INTEGER NOT NULL
    -> );
Query OK, 0 rows affected (0.02 sec)

上記のテーブルにいくつかレコードを追加しておく。 二分間におにぎりとパンとパスタがいくつか購入されている。

> INSERT INTO
    ->   orders
    -> VALUES
    ->   ('2017-06-30 20:00:00', 'Onigiri', 1),
    ->   ('2017-06-30 20:00:00', 'Pan', 2),
    ->   ('2017-06-30 20:00:00', 'Pasta', 1),
    ->   ('2017-06-30 20:00:10', 'Onigiri', 3),
    ->   ('2017-06-30 20:00:20', 'Pan', 2),
    ->   ('2017-06-30 20:00:30', 'Pasta', 1),
    ->   ('2017-06-30 20:01:00', 'Onigiri', 2),
    ->   ('2017-06-30 20:01:00', 'Pan', 2),
    ->   ('2017-06-30 20:01:00', 'Pasta', 2);
Query OK, 9 rows affected (0.00 sec)
Records: 9  Duplicates: 0  Warnings: 0

格納されたデータはこんな感じ。

> SELECT * FROM orders;
+---------------------+---------+--------+
| datetime            | code    | amount |
+---------------------+---------+--------+
| 2017-06-30 20:00:00 | Onigiri |      1 |
| 2017-06-30 20:00:00 | Pan     |      2 |
| 2017-06-30 20:00:00 | Pasta   |      1 |
| 2017-06-30 20:00:10 | Onigiri |      3 |
| 2017-06-30 20:00:20 | Pan     |      2 |
| 2017-06-30 20:00:30 | Pasta   |      1 |
| 2017-06-30 20:01:00 | Onigiri |      2 |
| 2017-06-30 20:01:00 | Pan     |      2 |
| 2017-06-30 20:01:00 | Pasta   |      2 |
+---------------------+---------+--------+
9 rows in set (0.00 sec)

特定の条件に一致するアイテムを確認する

このとき、特定のコードを持ったアイテムがどれだけ購入されたかを確認してみよう。 一般的には WHERE 句を使ってこうするはず。

> SELECT
    ->   *
    -> FROM orders
    -> WHERE code like 'Onigiri';
+---------------------+---------+--------+
| datetime            | code    | amount |
+---------------------+---------+--------+
| 2017-06-30 20:00:00 | Onigiri |      1 |
| 2017-06-30 20:00:10 | Onigiri |      3 |
| 2017-06-30 20:01:00 | Onigiri |      2 |
+---------------------+---------+--------+
3 rows in set (0.00 sec)

ただ、上記だと同時に一つのアイテムしか確認できない。 おにぎりとパンとパスタの売れ方を同時に時系列で見たい、という場合はどうすれば良いだろうか?

CASE 句で縦持ちのデータを横持ちに変換する

そういったときは CASE 句を使って新しいカラムを追加する。 一般的には条件分岐に用いる CASE 句だけど、次のようにすると特定の条件に一致する内容で新しいカラムを作れる。 これによって縦持ちのデータを横持ちにできる。

> SELECT
    ->   datetime,
    ->   CASE WHEN code like 'Onigiri' THEN amount ELSE 0 END AS 'Onigiri',
    ->   CASE WHEN code like 'Pan' THEN amount ELSE 0 END AS 'Pan',
    ->   CASE WHEN code like 'Pasta' THEN amount ELSE 0 END AS 'Pasta'
    -> FROM orders;
+---------------------+---------+-----+-------+
| datetime            | Onigiri | Pan | Pasta |
+---------------------+---------+-----+-------+
| 2017-06-30 20:00:00 |       1 |   0 |     0 |
| 2017-06-30 20:00:00 |       0 |   2 |     0 |
| 2017-06-30 20:00:00 |       0 |   0 |     1 |
| 2017-06-30 20:00:10 |       3 |   0 |     0 |
| 2017-06-30 20:00:20 |       0 |   2 |     0 |
| 2017-06-30 20:00:30 |       0 |   0 |     1 |
| 2017-06-30 20:01:00 |       2 |   0 |     0 |
| 2017-06-30 20:01:00 |       0 |   2 |     0 |
| 2017-06-30 20:01:00 |       0 |   0 |     2 |
+---------------------+---------+-----+-------+
9 rows in set (0.00 sec)

こうすれば、おにぎりとパンとパスタの売れ行きを同時に見られて嬉しい。

ちなみに条件に一致しないときのデフォルト値として ELSE 0 を指定しているのは地味に重要なポイント。 これを入れておかないと、どれにも一致しないときに NULL が返ってきてしまう。

> SELECT
    ->   datetime,
    ->   CASE WHEN code like 'Onigiri' THEN amount END AS 'Onigiri',
    ->   CASE WHEN code like 'Pan' THEN amount END AS 'Pan',
    ->   CASE WHEN code like 'Pasta' THEN amount END AS 'Pasta'
    -> FROM orders;
+---------------------+---------+------+-------+
| datetime            | Onigiri | Pan  | Pasta |
+---------------------+---------+------+-------+
| 2017-06-30 20:00:00 |       1 | NULL |  NULL |
| 2017-06-30 20:00:00 |    NULL |    2 |  NULL |
| 2017-06-30 20:00:00 |    NULL | NULL |     1 |
| 2017-06-30 20:00:10 |       3 | NULL |  NULL |
| 2017-06-30 20:00:20 |    NULL |    2 |  NULL |
| 2017-06-30 20:00:30 |    NULL | NULL |     1 |
| 2017-06-30 20:01:00 |       2 | NULL |  NULL |
| 2017-06-30 20:01:00 |    NULL |    2 |  NULL |
| 2017-06-30 20:01:00 |    NULL | NULL |     2 |
+---------------------+---------+------+-------+
9 rows in set (0.00 sec)

こうなると NULL の伝搬を考えなきゃいけなくなるので、ここからの集計処理が大変になる。

購入時刻でまとめる

ちなみにさっきの集計だと同じ時刻のものがそれぞれのアイテムごとに複数行で表示されてしまっていた。 こんなときは購入時刻で GROUP BY した上で、それぞれの CASE 句を SUM() 関数で合計すれば良い。

> SELECT
    ->   datetime,
    ->   SUM(CASE WHEN code like 'Onigiri' THEN amount ELSE 0 END) AS 'Onigiri',
    ->   SUM(CASE WHEN code like 'Pan' THEN amount ELSE 0 END) AS 'Pan',
    ->   SUM(CASE WHEN code like 'Pasta' THEN amount ELSE 0 END) AS 'Pasta'
    -> FROM orders
    -> GROUP BY datetime;
+---------------------+---------+------+-------+
| datetime            | Onigiri | Pan  | Pasta |
+---------------------+---------+------+-------+
| 2017-06-30 20:00:00 | 1       | 2    | 1     |
| 2017-06-30 20:00:10 | 3       | 0    | 0     |
| 2017-06-30 20:00:20 | 0       | 2    | 0     |
| 2017-06-30 20:00:30 | 0       | 0    | 1     |
| 2017-06-30 20:01:00 | 2       | 2    | 2     |
+---------------------+---------+------+-------+
5 rows in set (0.00 sec)

同じ時刻が一つのレコードで確認できるようになって、さらに見やすくなった。

一定の時刻の範囲で集計する

ただ、先ほどの表示を見ると、まだ問題が残っていることに気づくはず。 全く同じ時刻に購入されたものは一つのレコードで表現されているけど、秒数が異なるものは違うレコードになってしまっている。

そんなときは集計したい時刻の範囲を切り出して、それを GROUP BY でまとめる。 次のクエリでは時刻を一分単位までしか見ないようにして集計している。 これなら秒数が異なっても同じレコードとしてまとめることができる。

> SELECT
    ->   DATE_FORMAT(datetime, '%Y-%m-%d %H:%i') AS datetime_minute,
    ->   SUM(CASE WHEN code like 'Onigiri' THEN amount ELSE 0 END) AS 'Onigiri',
    ->   SUM(CASE WHEN code like 'Pan' THEN amount ELSE 0 END) AS 'Pan',
    ->   SUM(CASE WHEN code like 'Pasta' THEN amount ELSE 0 END) AS 'Pasta'
    -> FROM orders
    -> GROUP BY datetime_minute;
+------------------+---------+------+-------+
| datetime_minute  | Onigiri | Pan  | Pasta |
+------------------+---------+------+-------+
| 2017-06-30 20:00 | 4       | 4    | 2     |
| 2017-06-30 20:01 | 2       | 2    | 2     |
+------------------+---------+------+-------+
2 rows in set (0.00 sec)

ちなみに、時刻の扱いは RDBMS ごとにかなり異なるので、次の DATE_FORMAT() 関数を使った SQL は別の実装では動かないと思う。

まとめ

縦に色んなデータが入ったテーブルを横に並べるには CASE 句を使うと良い。

ビッグデータ分析・活用のためのSQLレシピ

ビッグデータ分析・活用のためのSQLレシピ

  • 作者: 加嵜長門,田宮直人,丸山弘詩
  • 出版社/メーカー: マイナビ出版
  • 発売日: 2017/03/27
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログを見る

SQL: COALESCE() 関数で NULL を別の値に置き換える

RDB のスキーマ設計において、なるべくなら Nullable なカラムは無い方が良いけど、そうもいかないときがある。 そんなときは COALESCE() 関数を使うと NULL になっているフィールドを別の値に置換できる。 これは、特定の値を NULL に置換できる NULLIF() 関数と対になるものかな。

blog.amedama.jp

動作確認に使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.12.5
BuildVersion:   16F73
$ mysql --version
mysql  Ver 15.1 Distrib 10.2.6-MariaDB, for osx10.12 (x86_64) using readline 5.1

もくじ

下準備

下準備として、例えば次のようなテーブルを作っておく。 何らかの商品の購入履歴のテーブルで、元々の金額といくらディスカウントがあったかを記録する。

> CREATE TABLE purchases (
    ->   id INTEGER,
    ->   amount INTEGER,
    ->   discount INTEGER,
    ->   PRIMARY KEY (id)
    -> );
Query OK, 0 rows affected (0.02 sec)

サンプルとなるレコードをいくつか追加しておく。 ここでディスカウントの金額が NULL になっているものがあるところに注意する。 ディスカウントがなかったときは 0 ではなく NULL が入るということ。

> INSERT INTO
    ->   purchases
    -> VALUES
    ->   (1, 1000, NULL),
    ->   (2, 2000, 500),
    ->   (3, 3000, 1000);
Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0

格納されるデータはこんな感じ。

> SELECT * FROM purchases;
+----+--------+----------+
| id | amount | discount |
+----+--------+----------+
|  1 |   1000 |     NULL |
|  2 |   2000 |      500 |
|  3 |   3000 |     1000 |
+----+--------+----------+
3 rows in set (0.00 sec)

NULL を置換せずに計算した場合

何も考えずにディスカウントが適用された金額を計算してみよう。 これは、ただ単に金額からディスカウントを引くだけ。

> SELECT
    ->   id,
    ->   amount - discount AS discounted_amount
    -> FROM purchases;
+----+-------------------+
| id | discounted_amount |
+----+-------------------+
|  1 |              NULL |
|  2 |              1500 |
|  3 |              2000 |
+----+-------------------+
3 rows in set (0.00 sec)

するとディスカウントの値が NULL だったところの内容が NULL になってしまった。 これは NULL の伝搬と呼ばれるもので、計算内容に NULL が混入していると結果が全て NULL になってしまうというもの。

COALESCE() 関数で NULL を置換する

この NULL の伝搬を防ぐには NULL になっている値を何らかの値に置換してやれば良い。 今回の例であれば NULL0 に置き換えてしまえば大丈夫なはず。 そんなとき使えるのが今回紹介する COALESCE() 関数で、これは第一引数が NULL だったときに第二引数の値に置換できる。

先ほどのクエリに COALESCE() 関数を使ってみよう。

> SELECT
    ->   id,
    ->   amount - COALESCE(discount, 0) AS discounted_amount
    -> FROM purchases;
+----+-------------------+
| id | discounted_amount |
+----+-------------------+
|  1 |              1000 |
|  2 |              1500 |
|  3 |              2000 |
+----+-------------------+
3 rows in set (0.00 sec)

今度は結果が NULL にならず、ちゃんとした値になった。

まとめ

  • スキーマ定義にはなるべく Nullable なカラムはない方が良い
    • とはいえ、そうもいかないときがある
  • 計算内容に NULL が含まれると、それが伝搬してしまう
  • それを防ぐには COALESCE() 関数を使うと良い

ビッグデータ分析・活用のためのSQLレシピ

ビッグデータ分析・活用のためのSQLレシピ

SQL: NULLIF() 関数を使ってゼロ除算を防ぐ

RDBMS によってはゼロ除算をするとエラーになってしまうものがある。

例えば PostgreSQL とかがそう。

$ psql --version
psql (PostgreSQL) 9.6.3

次のようにゼロ除算をするとエラーになって結果が得られない。 これは SQL を使って演算するときに問題となることがある。

# SELECT 1 / 0;
ERROR:  division by zero

そんなときは NULLIF() 関数を使って意図的に値を NULL にしてしまうことが考えられる。 数値と NULL の計算であれば NULL の伝搬によって結果もまた NULL となるのでエラーにはならない。

次のクエリでは NULLIF() 関数の引数が両方とも 0 になっているけど、本来なら最初の変数が何らかのカラムの名前になるはず。 結果が空っぽに見えるのは PostgreSQL はデフォルトで NULL を空白として表示するからみたい。

# SELECT 1 / NULLIF(0, 0);
 ?column?
----------

(1 row)

これはようするに第一引数の中身が第二引数と一致したら、それを NULL に置き換えるということ。

# SELECT NULLIF(0, 0);
 nullif
--------

(1 row)

# SELECT NULLIF(1, 0);
 nullif
--------
      1
(1 row)

ゼロ除算がエラーにならない RDBMS もある

ゼロ除算してもエラーにならない実装もあるようだ。 例えば MariaDB とか。

$ mysql --version
mysql  Ver 15.1 Distrib 10.2.6-MariaDB, for osx10.12 (x86_64) using readline 5.1

次のようにゼロ除算を発生させると結果が自動的に NULL になる。

> SELECT 1 / 0;
+-------+
| 1 / 0 |
+-------+
| NULL  |
+-------+
1 row in set, 1 warning (0.00 sec)

あとは厳密には RDBMS じゃないけど SQL の処理系という意味では Hive とか。

$ hive --version
Hive 1.2.2
Subversion git://vgumashta.local/Users/vgumashta/Documents/workspace/hive-git -r 395368fc6478c7e2a1e84a5a2a8aac45e4399a9e
Compiled by vgumashta on Sun Apr 2 13:12:26 PDT 2017
From source with checksum bd47834e727562aab36c8282f8161030

こちらもゼロ除算を発生させると結果が NULL になった。

> SELECT 1 / 0;
OK
NULL
Time taken: 0.094 seconds, Fetched: 1 row(s)

まとめ

  • ゼロ除算するとエラーになる RDBMS がある
  • そのときは NULLIF() 関数を使って結果を NULL にするとエラーを防げる
  • そもそもゼロ除算がエラーにならず NULL になる処理系もある

ビッグデータ分析・活用のためのSQLレシピ

ビッグデータ分析・活用のためのSQLレシピ

  • 作者: 加嵜長門,田宮直人,丸山弘詩
  • 出版社/メーカー: マイナビ出版
  • 発売日: 2017/03/27
  • メディア: 単行本(ソフトカバー)
  • この商品を含むブログを見る

ちなみに反対に NULL を特定の値に置換するのには COALESCE() 関数を使う。

blog.amedama.jp