Site Reliability Engineering: How Google Runs Production Systems の読書メモ

最近Site Reliability Engineer(SRE)という職種やチームがよく話題になります。当初は、コードをよく書けるインフラエンジニアがふつうにやっていたことに名前を付けただけと思っていましたが、Googleの出しているSRE本(Site Reliability Engineering: How Google Runs Production Systems)を読むと、考え方としても従来とは異なる面がありそうと思ったので、そのあたりをメモしておきます。主に1部のIntroductionと2部のPrinciplesを参考にしています。

SREとは

  • Site Reliability Engineeringとは

    • ソフトウェアシステムのライフサイクル全体をカバーする分野。投入から運用、改良、退役まで。
    • Software Engineering(≒ソフトウェアの設計から構築までをカバーする分野)との対比から。
  • Site Reliability Engineerとは

    • 設計開発から構築までやるエンジニア。ソフトウェアも書くし、ロードバランスやバックアップの仕組みづくりもやる。
    • 信頼性にフォーカスする。サービスの可用性、レイテンシ、パフォーマンス、効率性、変更管理、監視、障害対応、キャパシティプランニングに責任をもつ。
    • ソフトウェアをより信頼できるものにするために、手続き、プラクティス、ツールを作る。ただし、これらが開発スピードを落とさないようにする。「システムのAgilityとStabilityのバランスを取る仕事」
  • Site Reliability Engineerのスキルセット

    • Software Enginnerとしてのスキル
    • UNIXシステム内部やL1-L3ネットワークの専門性

SREの考え方1: 開発と運用の時間配分

業務時間の50%以上は開発につかう。逆に、運用の仕事は50%までにする。そうなるように、自動化の仕組みを作る。結構シビアな数字らしく、運用が50%を超えるときは開発者に運用タスクを頼むこともある。

SREの考え方2: Error Budget

スピードを重視したい開発者と安定性を重視したい運用者の間の緊張はありがち。どれぐらい障害に備えるか、どれぐらいテストするか等の方針がぶつかりやすい。データを元にそこを判断するために、Error Budgetの考え方を導入した。

サービスのSLO (Service Level Objective)からError Budgetを計算する。SLOが99.99%ならError Budgetは0.01%。障害等があるとError Budgetが減っていく。これがなくならないうちは、リスクをとって早いペースでリリースする。なくなりそうになったら、テストや安定化に時間をかけ、リスクを避ける。

面白いのは、Error Budgetが全然減らないのを問題と考えるところ。Error Budgetが減らない → 品質に必要以上の時間をかけている → 他のことに使えた時間を無駄にした、という理屈。

Googleの場合、エラーは稼働時間で計算しないらしい。システムが地理的に分散しているので、時間ベースの指標だと正確な稼働率にならないため。代わりに、成功したリクエストの割合をつかう。

SREの考え方3: 自動化

5段階の自動化がある。単に自動化スクリプト作りましたというだけでは不十分。

  1. 自動化なし。手作業。
  2. 自動化スクリプトがある。でも個人が管理していて、他の人が使えない。
  3. 誰でも使える自動化スクリプトがある。スクリプトの作成はSREチームの担当。課題としては、開発チームとして自動化しやすいシステムを作る動機が弱く、自動化に関する技術的負債がSREチームに来やすいところ。また、他チームが入れたシステム変更をフォローして自動化スクリプトを更新していくのは大変。
  4. 自動化スクリプトがプロダクトの一部として同梱される(開発チームが自動化スクリプトを作成する)
  5. 自動化スクリプトの実行まで含めて自動化。システムが自分で問題に気づいて、自動で修正する。また、管理サーバがあり、そこからシステムの各種操作ができる。そのサーバはRPCインターフェースをもっていて、必要なときに他のシステムから叩ける。

本文には書いてないけど、AWSとかGCPのサービスは、5段階目に到達したシステムの例と考えてもいいのでは。

まとめ

GoogleのSRE本を読んだときのメモでした。内容も参考になりましたが、「できるだけがんばる」という考え方に陥りやすいところを、数値や体系を使って具体的な目標に落としていく姿勢が特にいいなと思いました。

AWS WAFをバイパスしてSQLインジェクション攻撃をしてみる

最近はAWS WAFを触っています。こういう防御ツールは、やはり攻撃をどれぐらい防いでくれるか気になります。AWS WAFの場合、SQLインジェクション系の脆弱性を探ってくれるsqlmapをかけたところ、攻撃をブロックしてくれたという記事があります。

記事を読んだり自分でちょっと試したりして、ちゃんとSQLインジェクション攻撃を防いでくれるんだーと思っていました。が、つい先日WAFをバイパスしてSQLインジェクション攻撃をするテクニックがあることを知りました。例えばOWASPのこのページには、そういうテクニックがいくつか紹介されています。

こうなると気になるのは、AWS WAFに対してWAFバイパスのテクニックを使うとどうなるかです。というわけで、実際に試してみました。

単純にSQLインジェクションしてみる

まずは、AWS WAFがないときにSQLインジェクションができること、また、AWS WAFを設定すると同じ方法ではSQLインジェクションができなくなることを確認します。

今回は雑に書いたPython製Webアプリを使います(ソース)。idpasswordパラメータを付けてGET /loginすると、DBに該当するユーザがいるかを確認し、いれば200 OK、いなければ401 Unauthorizedレスポンスを返します。内部では次のようにして作られたクエリが実行されています。

"select id from user where id='%s' and pass='%s'" % (username, password)

今回はこのWebアプリのSQLインジェクション脆弱性を突いて、認証を不正にパスしてみます。AWS WAFがない場合、次のようなリクエストでできます。

http://example.com/login?id=admin&password=' or 1=1 -- 

内部的には、次のようなSQLクエリが発行され、200 OKが返ります。

select id from user where id='admin' and pass='' or 1=1 -- '

次に、AWS WAFを設定します。設定はこちらの記事を参考にしました。

AWS WAFを設定して同じリクエストを送ってみると、403 Forbiddenレスポンスが返ります。WAFのログを見ると、リクエストがブロックされたことがわかります。

f:id:ks888:20160516152603p:plain

WAFバイパスのテクニックをつかってみる1

OWASPのページには数種類のテクニックが紹介されています。まずはNormalization Methodを試してみます。/**/などのコメントを適宜はさむ方法のようです。

いくつかの箇所にこの/**/を挟んでみたのですが、どれもブロックされました。例えば次のようなクエリを送ってみました。

http://example.com/login?id=admin&password=' /**/or/**/ 1/**/=/**/1 -- 

似た方法として、クエリの一部を/*!*/で挟む方法があるようです。MySQLだと/*!*/で囲まれた文字列がそのまま実行される性質を利用しています(MySQLのドキュメント)。次のようなクエリです。

http://example.com/login?id=admin&password=' /*!or 1=1*/ -- 

ただこれもブロックされました。手強いですね。

WAFバイパスのテクニックをつかってみる2

次に紹介されていたのはHTTP Parameter Pollutionという方法とHTTP Parameter Fragmentationという方法です。どちらも、SQLインジェクションを狙った文字列を、複数のパラメータに分けて送る方法です。

HTTP Parameter Pollutionは、例えば/login?password=A&password=Bのように、同じパラメータが複数回登場するクエリを送る方法です。サーバ側がこれをA,Bのような文字列にして処理してくれれば、SQLインジェクションのための文字列をAとBに分けて送ることができます。

ただしこの方法は、サーバ側がA,Bという文字列を作ってくれなければ、使えません。今回用意した環境だと、最後に出現した値のみが使われ、Bという文字列しか作られません。なのでHTTP Parameter Pollutionの利用は今回はスキップします。

HTTP Parameter Fragmentationはもっと単純で、複数の異なるパラメータが結合した時に、SQLインジェクションのための文字列ができるようにする方法です。次のようなリクエストを送ります。

http://example.com/login?id=admin'/*&password=*/ -- 

内部的には、次のようなSQLクエリが発行されます。

select id from user where id='admin'/*' and pass='*/ -- '

このクエリはWAFをバイパスして、200 OKが返りました。キタ!

WAFバイパスのテクニックをつかってみる3

最後に紹介されていたのは、SQLインジェクションを狙った文字列の一部を、同じ意味をもつ他の文字列に置き換える方法です。1=12<3に置き換えて、次のようなリクエストを送ってみます。

http://example.com/login?id=admin&password=' or 2<3 -- 

これはブロックされました。他にも以下の文字列等を試してみましたが、どれもブロックされました。

http://example.com/login?id=admin&password=' or 2<3 -- 
http://example.com/login?id=admin&password=' or 1 -- 
http://example.com/login?id=admin&password=' or 'a'='a' -- 
http://example.com/login?id=admin&password=' or 'a'<>'b' -- 
http://example.com/login?id=admin&password=' or 1+1<3 -- 

まとめ

AWS WAFをバイパスしてSQLインジェクションしてみました。とりあえずHTTP Parameter Fragmentationという方法なら、実際にAWS WAFをバイパスできることがわかりました。WAFはかなりブロックしてくれて助かりますが、やはり万能ではないので、Webアプリの実装にセキュリティ対策を入れ込むのは大事だなと思いました。

ApacheのDigest認証モジュールがどのようにnonceを生成しているか

前回の記事で、Digest認証がどうやって攻撃に対策しているかまとめました。いくつかの攻撃への対策として、nonceと呼ばれるワンタイムトークンが重要な役割を果たしていました。このnonceの生成方法は実装者に任せられているのですが、実際のWebサーバではどうしているのでしょうか。生成文字列が第三者に予想できるような方法はよくなさそうです。nonceの生成日時がnonceに含まれているとexpire判定に役立ちそうです。まぁ考えていても分からないので、Apacheではどのようにnonceを生成しているか、調べてみました。

今回見たのは、2016年5月時点で最新の安定版である2.4.20のコードです。こちらから取得できます。

nonceの生成箇所

まずはどこでnonceを生成しているか、確認します。ApacheのDigest認証周りはmod_auth_digestモジュールにまとめられています。

mod_auth_digestモジュールはmodules/aaa/mod_auth_digest.cソースコードがあります。認証に失敗するとnote_digest_auth_failure()という関数が呼ばれ、その中でWWW-Authenticateヘッダを生成します。WWW-AuthenticateヘッダはDigest認証で401 Unauthorizedが返されるときに付与されるヘッダーです。

note_digest_auth_failure()の真ん中辺り(modules/aaa/mod_auth_digest.cの1285行目〜1287行目)でnonceを、末尾(modules/aaa/mod_auth_digest.cの1318行目〜1326行目)でヘッダーを生成しています。

    /* Setup nonce */

    nonce = gen_nonce(r->pool, r->request_time, opaque, r->server, conf);
    apr_table_mergen(r->err_headers_out,
                     (PROXYREQ_PROXY == r->proxyreq)
                         ? "Proxy-Authenticate" : "WWW-Authenticate",
                     apr_psprintf(r->pool, "Digest realm=\"%s\", "
                                  "nonce=\"%s\", algorithm=%s%s%s%s%s",
                                  ap_auth_name(r), nonce, conf->algorithm,
                                  opaque_param ? opaque_param : "",
                                  domain ? domain : "",
                                  stale ? ", stale=true" : "", qop));

見ての通り、gen_nonce()という関数がnonceの生成箇所であることがわかります。

nonceの生成方法(前半)

gen_nonce()は次のような関数です(modules/aaa/mod_auth_digest.cの1067行目〜1093行目)。

/* The nonce has the format b64(time)+hash .
 */
static const char *gen_nonce(apr_pool_t *p, apr_time_t now, const char *opaque,
                             const server_rec *server,
                             const digest_config_rec *conf)
{
    char *nonce = apr_palloc(p, NONCE_LEN+1);
    time_rec t;

    if (conf->nonce_lifetime != 0) {
        t.time = now;
    }
    else if (otn_counter) {
        /* this counter is not synch'd, because it doesn't really matter
         * if it counts exactly.
         */
        t.time = (*otn_counter)++;
    }
    else {
        /* XXX: WHAT IS THIS CONSTANT? */
        t.time = 42;
    }
    apr_base64_encode_binary(nonce, t.arr, sizeof(t.arr));
    gen_nonce_hash(nonce+NONCE_TIME_LEN, nonce, opaque, server, conf);

    return nonce;
}

重要なのは最後のほうで、apr_base64_encode_binary()関数から得られる文字列と、gen_nonce_hash()関数から得られる文字列を結合したものが、nonceです。apr_base64_encode_binary()関数からは、タイムスタンプをBase64エンコーディングした文字列が得られます。gen_nonce_hash()関数からは、いくつかの値をまとめてSHA1ハッシュ化した文字列が得られます。

実際のnonceは、例えば次のようになっています。

3QrlcEIyBQA=69dbf3121f8c6d36372601c08c7845ec9d59fa8c

突然=が現れるのが以前から不思議だったのですが、nonceの前半の文字列がBase64だったからなんですね。この文字列をデコードすると、apr_time_tという構造体の値が得られます。apr_time_tは、UNIXタイムのus精度版です。

なぜタイムスタンプだけハッシュ化ではなくBASE64エンコーディングされているかというと、おそらくこの値を、nonceがexpireしているかどうかの判定に使うためだと思います。実際check_nonce()という関数では、nonceから導出されるタイムスタンプを元に、expire判定をしています。BASE64エンコーディングなら元の値を復元できるので、このような実装ができます。

nonceの生成方法(後半)

次に、gen_nonce_hash()関数をみてみます(modules/aaa/mod_auth_digest.cの1039行目〜1064行目)。

/* The hash part of the nonce is a SHA-1 hash of the time, realm, server host
 * and port, opaque, and our secret.
 */
static void gen_nonce_hash(char *hash, const char *timestr, const char *opaque,
                           const server_rec *server,
                           const digest_config_rec *conf)
{
    unsigned char sha1[APR_SHA1_DIGESTSIZE];
    apr_sha1_ctx_t ctx;

    memcpy(&ctx, &conf->nonce_ctx, sizeof(ctx));
    /*
    apr_sha1_update_binary(&ctx, (const unsigned char *) server->server_hostname,
                         strlen(server->server_hostname));
    apr_sha1_update_binary(&ctx, (const unsigned char *) &server->port,
                         sizeof(server->port));
     */
    apr_sha1_update_binary(&ctx, (const unsigned char *) timestr, strlen(timestr));
    if (opaque) {
        apr_sha1_update_binary(&ctx, (const unsigned char *) opaque,
                             strlen(opaque));
    }
    apr_sha1_final(sha1, &ctx);

    ap_bin2hex(sha1, APR_SHA1_DIGESTSIZE, hash);
}

おおまかには、次のような流れです。

  • ハッシュ化するデータを変数ctxに詰めていく(memcpy()とapr_sha1_update_binary())
  • SHA1ハッシュを計算する(apr_sha1_final())
  • ハッシュをバイナリから16進数に変換する(ap_bin2hex())

最初にmemcpy()しているのは、実は&conf->nonce_ctxに中間データが入っているからです。最初にDigest認証の設定を読み込むときに呼ばれるset_realm()という関数の中で、次のように中間データを作っています(modules/aaa/mod_auth_digest.cの474行目〜481行目)。

    /* we precompute the part of the nonce hash that is constant (well,
     * the host:port would be too, but that varies for .htaccess files
     * and directives outside a virtual host section)
     */
    apr_sha1_init(&conf->nonce_ctx);
    apr_sha1_update_binary(&conf->nonce_ctx, secret, sizeof(secret));
    apr_sha1_update_binary(&conf->nonce_ctx, (const unsigned char *) realm,
                           strlen(realm));

secretというのはDigestモジュール初期化時に生成される文字列です。realmには設定ファイルでAuthNameとして指定した文字列が入ります。apr_sha1_update_binary()関数をつかってこれらの文字列を詰めています。

gen_nonce_hash()に戻って、次はtimestrという文字列を詰めています。この文字列は、先ほどnonceに詰めた、タイムスタンプをBase64エンコーディングした文字列です。

次は、opaque変数がNULLでない場合にopaqueが示す文字列を詰めています。ただし実際の挙動をみると、opaque変数はNULLか空文字列を指していて、ハッシュ値には影響がないようです。

まとめると、nonceの後半の文字列はsecret、realm、タイムスタンプをSHA1ハッシュして生成されます。予想ですが、secretはnonceを攻撃者が予想できないようにするため、タイムスタンプはnonceを以前の値とかぶらないようにするために使われているのではと思いました。realmは正直よくわかりませんが、同じサーバで他にDigest認証をかけている場合に、nonceがかぶらないようにするためでしょうか。

生成方法の確認

確認のため、手で生成した結果とくらべてみます。実際のnonceがROZROU0yBQA=0d3d7d1e0589ebe3eef43bb3b5b6ec932ba9e1b4で、secretが\x82\xd4\xe3\xe4\xb1t\xba\x9e\xf1\x1b\x9e\xdf\x805\x7fcs\xae\xd0\xd0、realmがsecretとします。

このとき、SHA1("\x82\xd4\xe3\xe4\xb1t\xba\x9e\xf1\x1b\x9e\xdf\x805\x7fcs\xae\xd0\xd0" + "secret" + "ROZROU0yBQA=")は、0d3d7d1e0589ebe3eef43bb3b5b6ec932ba9e1b4になり、実際のnonceの後半部分と一致します。

その他気づいたこと

Apacheはデフォルトの設定だと、nonce-countのチェックをしていないようです。Digest認証をパスしたときのAuthorizationヘッダーをコピペして、同じリクエストをcurlから送ってみたら、200 OKが返ってきました。リプレイアタックできてしまいますね。

対策として、AuthDigestNcCheck Onという設定をDigest認証の設定ファイル(.htaccessとか)に書いておくと、チェックしてくれるようになりました。ドキュメントに書いてない設定なので、将来的にどうなるかわかりませんが。

まとめ

ApacheのDigest認証モジュールが、どのようにnonceを生成しているか確認しました。割とシンプルですが、実用上十分な方法な気がします。RFCにはクライアントのIPをつかう案とかETagをつかう案とか書いてありますが、使ってないみたいですね。