Webエンジニアのセキュリティ特集
はじめに
みなさんこんにちは、ALH開発事業部のREIYAです。
今回はWebエンジニアがアプリレイヤで意識するべきセキュリティ観点をまとめました。
インフラレベルでの対応も知っておくべきですが、とりあえずアプリ側でできる制御を具体的にまとめます!
結局こういうのは手を動かしたほうが実感できるので、Try Hack Meなどをはじめとするこれらのサイトで実際にハッキングしてみると楽しいです。
ハッシュ化とストレッチング
昨今CognitoなどがありますのでDB腹持ちすることがなくなりつつありますが、パスワード等をまさか平文のまま登録するわけにはいきませんので
ハッシュ化をすることで復元が現実的でない状態にします。
難しい話抜きでただ使うためだけの説明をします。
平文 → ハッシュ文字列 にすることをハッシュ化。
例
passw0rd -> aU7sHJs6lsPjaFAEdhk....
また、ハッシュ化した場合、元の平文に戻すことは現代の計算機科学的に困難です。
余談ですが、ハッシュ化でなく「暗号化」は、元に戻せるものです。
また、ハッシュ化にはソルトという文字列が必要ですので
先ほどの例をより正しく書き直すと
passw0rd + ソルト文字 -> aU7sHJs6lsPjaFAEdhk....
となります。平文に戻せない代わりに、同じ平文と同じソルト文字でハッシュ化すると必ず同じ結果の文字列になりますので、毎回ハッシュ化して値の一致を確認します。
ソルトは通常、ハッシュ後の文字列に含めて持っておきます。ソルトだけバレても復元できないのでOK
具体的にはこうです。
Pythonのhashlib.sha256というものは、ハッシュの中でもSHA256というアルゴリズムを用いたハッシュ関数を使用しています。
他にもファイルのチェックサムなどでよく使われるMD5等のアルゴリズムがあります。
import hashlib
def _hash_password(password, salt):
# hash with sha256
pass_byte = bytes(password, 'utf-8')
digest = hashlib.sha256(salt + pass_byte).hexdigest()
# return salt+hash
salt_str = salt.decode('utf-8')
return f'{salt_str}$${digest}' # ソルトがわからなくならないよう、ハッシュ後文字に入れておく
ソルト文字は適当に
import secrets
token = secrets.token_hex()
をbyteにして使ったり
import base64
salt = base64.b64encode(os.urandom(32))
のように生成しておけば良いと思います。個人的には後者がおすすめ。
また、平文とハッシュ値を比較するにはこのように、再度ハッシュ化すればよいですね。
def _hash_check(plain, password):
salt = password.split('$$')[0]
digest = _hash_password(plain, bytes(salt, 'utf-8'))
return password == digest
ストレッチングという、より高セキュアにする手法もあります。
パスワード破りの代表であるbrute force、辞書攻撃等の網羅的な力技だと
例えばハッシュ値とソルトが漏洩した場合、めっちゃいろんな文字でハッシュ化ためしまくれば当たっちゃうことがあります。
ハッシュ値(DBが漏れたとする)
ソルト(通常ハッシュ値にくっつけて保管される)
ハッシュに用いているアルゴリズム(よくSHA256が使われる)
これだけの情報が揃っていると、passw0rd程度の文字ならすぐ破られてしましますので
なんどもハッシュ化する処理にしておく、すなわち
passw0rd + ソルト文字 -> aU7sHJs6lsPjaFAEdhk....
aU7sHJs6lsPjaFAEdhk.... -> もっかいハッシュ化 Ejds7AShh4lxc8t3Asd....
Ejds7AShh4lxc8t3Asd.... -> もっかいハッシュ化 ....
....
のようにすることで、何回ハッシュ化するかという選択肢も設けることで、攻撃者の総当たりに必要な幅を増やすことができます。
以下では先ほどの関数を、1万回ハッシュ化を繰り返した文字を返却するようにしたもの。
import hashlib
def _hash_password(password, salt):
pass_byte = bytes(password, 'utf-8')
digest = hashlib.sha256(salt + pass_byte).hexdigest()
# stretching
for _ in range(1e4):
digest = hashlib.sha256(bytes(digest, 'utf-8')).hexdigest()
salt_str = salt.decode('utf-8')
return f'{salt_str}$${digest}'
まぁこういったことはなるべく自力実装しないのが一番ですので、ライブラリやサービスがあるなら積極的にそちらを採用しましょう。
cookieフラグ
昨今のWebアプリではフロント側に認証結果を持ちsession管理をすることも多いと思いますが
フロントはJavaScriptで(ほぼ)なんでもできちゃうので、よく気をつけたいですね。
cookieに認証トークンであったりを格納した際、これをバックエンド側へ送信し
HTTPリクエストヘッダ等と併せるなどして認証を行う場合を想定しましょう。
cookieにはsame-siteフラグというフィールドがあります。
ざっくりまとめると、これがあれば別サイトへcookieを送らないようにできるもので、詳しくは長いのでこちらなどを参考にしてください。
よくわからなければとりあえずStrictにしておけばいいです。
各ブラウザでのsame-siteフラグ対応状況はこちらです。
未対応の場合は後述の対策が必須です。
その他のCSRF対策
前述のsame-siteが手軽なCSRF対策のうちの一つですが、ブラウザ未対応であった頃やどうしてもcookie関連では防げない場合でのCSRF対策はどうしたらよいでしょうか。
CSRFについて基本的な部分はこちらが非常に読みやすいのでぜひ。
さて、対策でよく使われるのが
CSRFトークン
HTTPリファラヘッダ
HTTPカスタムヘッダ
などです。
CSRFトークン
例えばHTML内部にトークン文字列を埋め込み、毎リクエスト時にリクエストに含める等することで
localStorageやcookieを介さずトークンをストアできるという、いにしえの手法です。
元々こういった手法を、HTTPヘッダにいれろや!と進化していきました。
HTTPリファラ
HTTPヘッダにはRefererというフィールドがあります。JMeter等で負荷試験などやる際によく出てくると思いますが、リクエスト先に行く直前のウェブページのアドレスを持ちます。
直前のアドレスが別サイトであれば攻撃と判断するような処理を入れるなどする方針ですね。
HTTPカスタムヘッダ
たとえば
curl -H 'Content-Type:application/json' https://google.com/
とする時のContent-Typeは予約されたヘッダですが、独自で追加することももちろんできます。
以下のように使われることが多いです
curl -H 'X-Auth-Header: xxxxxxx' https://google.com/aaa/...
認証関連のヘッダはAuthorizationが証明書用に予約されています。
Xから始まるものはユーザカスタム系のヘッダと昔はなってましたが、最近ではいろいろ物議を醸しています。
参考
で、結局これらをどう使うかは自由ですが、独自実装よりもやはりライブラリに頼りたいということで
各言語でよく使われるサーバのライブラリでのCSRF対策まとめがこちらにありました。
ディレクトリトラバーサル
たとえば
https://test.com/../../file.txt
みたいなことをされて大丈夫かということです。
apacheの初期設定等ではサーバ中の
/var/www/html/
このパス配下について公開に設定されていますが
ディレクトリへアクセスした際、ブラウザ上なのにエクスプローラのようにファイル一覧が見られてしまうことがありますよね?
以下のように、サーバのファイルシステムへのアクセスを許可しない設定を入れることで基本的には対応できますが
<Directory />
AllowOverride none↲
Require all denied↲
</Directory>
サーバ内でlocalhostを立てた際などに、プロジェクトフォルダ配下に鍵ファイルを残していたりした場合、予期せぬ公開に備えなくてはいけないので
鍵ファイルは必ず別の場所で管理するようにしましょう。
XSS
セッション系の攻撃がCSRFですが、XSSは入力欄に任意コードいれでドーンというものですね。
対策はひとえにサニタイズするだけですが、任意コードを実行できる脆弱性はいまだに潜んでいることが多いです。
昨今の身近な例ではlog4jやxz-utilのバックドアなどがありました。
apacheのlog4jはJava系のSlf4jなどで気づいたら使っていたなんてことも多いです。xz-utilはmacでaws-cliを入れていればbrewの依存関係で必ず入っていますし、身近すぎて怖いですね。
ところでdataURLというものをご存知でしょうか?
以前私の書いたこちらのブラウザ簡易エディタであったり、これなんかもdataURLで表示することだってできます。
data:text/html,<canvas id=C><script>f=()=>{R=n*3**.5/4,p=p.flatMap(([x,y])=>{t=[[x-n/4,y-R],[x+n/4,y+R],[x-n*3/4,y+R]];return ~k?k?t:[t[0]]:p}),n/=2;p.map(([x,y])=%3E{with(C.getContext`2d`){beginPath();moveTo(x,y);lineTo(x-n,y);lineTo(x-n/2,y+(~k?R:-R));closePath();stroke()}});k++%3CM&&setTimeout(f,a)};f(M=6,k=-1,p=[[C.width=C.height=a=1e3,a]],n=a*2)%3C/script%3E
ブックマークレットだって結局はURL上でJSを実行できる仕組みです。
リテラシーの低い方向けに、devツールへのコピペ禁止対策が行われている某狐ブラウザもあります。サイトによっても注意書きをconsoleに出しているものもあります。
とにかく任意のJSをブラウザでどうにでも実行できてしまうので、XSSは厄介ということです。実際フロントエンドをJSで動かしている以上全ての任意なスクリプト実行を無効にすることは絶対に不可能で
devツールのネットワークタブからリクエストのfetchコードを取得
これによってJSのfetchでのリクエストをconsoleから行えるapplicationタブからsessionStorage、localStorage、indexedDB等を参照できる
Sourceタブからwebpack後であればjsが見れる
React等であっても拡張機能を用いてDOMツリーdebugができる
などなど、開発者にとって便利な機能は全てハッカーにも便利です。
余談ですがWebアプリの全てをURLに入れてしまおうという記事もあります。
このように、入力フォーム以外からの攻撃をXSSと呼ぶかは定かではありませんが、広義的に任意コード実行と捉えると
少なくとも我々開発者側が取れる防御策は
サニタイズ
API側での徹底したバリデーション
不要なフロントStorageへの格納をなくす
そもそも不要な情報をフロントに渡さない
などを徹底することしかありません。
SQLインジェクション
XSSの中でもSQLに特化したものがこちらですね。
サニタイズしたら終わりなんですが、preparedStatementなど
ORMapperライブラリ等を用いないベタな言語↔︎SQLやりとりでは、よくサニタイズが忘れられがちな気がします。
よくあるのが
SELECT * FROM table WHERE 1 = 1 OR ... ;
のように、1 = 1 ORという、後ろに何をつけても絶対に真になる条件を送るやり方ですね。
情報の抜き取りとしてはこれ等が挙げられますが、最も恐ろしいのはトランザクションの効かない破壊的操作TRUNCATEやDROPです。
(トランザクションが効いたところでコミットされてたら意味ないけども)
前述のXSSと同様な対策しかありませんし、昨今のライブラリはどれもしっかりしてますので
下手に独自実装でもしない限り踏むことはないと思います。
桁溢れ
さて、サニタイズやバリデーションだけでは地味に防げないものがあります。
攻撃とはちょっと違いますが、意図的に起こせてしまうバグの対策が必要で
プログラム上で表現できる桁数の限界を突破する場合です。
前述のようにfetchAPI等でJSからいくらでも好きな値をバックエンドに送れるわけですが、JSONでAPIに値を渡す際以下のJSON.key.valは数値として解釈されますが
{
"key": {
"val": 1000000000000000000000000000000
}
}
これは$${10^{30}}$$です。
さて、これをバックエンド側で受け取ったあとにJSONをオブジェクトへ変換すると
大体の場合$${-2147483647}$$になっていることでしょう。
intは通常32bit整数、longは64bitであることが多いです。doubleは64bit浮動小数ですが、C言語などでは桁数表現に11bit程度を使い
52bit程度しか精度がありません。
ちなみにPythonでは桁とかないので溢れることはありませんが重いです。
だいたいですが、intは$${10^9}$$程度まで、longなどであれば18乗程度までと覚えておきましょう。
CORS
こちらは対策できるものというより、CORSポリシー違反でAPI疎通ができない!みたいなことが多いので載せておきます。
よくCORSエラーでつまずく人が多い印象ですが、確かにいろいろなことを知る必要があってややこしいです。
いろんな記事がありますが、とにかくわかりにくいのですごく簡単にまとめてみました。
今、あなたはバックエンドサーバをhttps://test.comとして建てました。
さらにAPIをhttps://test.com/api1
にリクエストするように作りました。フロントのJSからajax(axiosでもfetchでもXmlHttpRequestでもよい)でここにアクセスして、JSONを受け取りたいとします。
フロントはReactなどで作ったのでlocalhost:3000だとする
よくあるやつですね。
衝撃の事実
Cross Origin つまり違うドメイン間だと、JSからバックエンドへAPIリクエストできない!
設定するとできるようになる!
Origin = ドメインと思っておいて良いです。
ポートも含めFQDNが同じものがSameOriginです。参考
よって、localhost:3000からhttps://test.com/api1へのリクエストは、CrossなOriginへのリクエストですね?
だからダメです。そういうもんなんです。諦めてください。
正しい挙動
フロントは変えなくて良いです。バックエンド側で、アクセスを許すドメインを登録するだけです。
1️⃣フロント→バックへリクエスト
2️⃣バック→フロントへレスポンス
としていましたね?今度からは
1️⃣フロント→バックへプリフライトリクエスト
2️⃣バック→フロントへいったんレスポンス
3️⃣フロント→バックへリクエスト
4️⃣バック→フロントへレスポンス
となるだけです。事前に1回やりとりするだけ。
より詳しい実装はものによりますがたとえばGinならこんな感じです。
package main
import (
"io/ioutil"
"log"
"net/http"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// create CORS config
r.Use(cors.New(cors.Config{
AllowOrigins: []string{
// 許可するフロント側のオリジンを設定する
"http://localhost:3000",
"http://127.0.0.1:3000",
},
AllowMethods: []string{
"POST",
"GET",
"OPTIONS", // for preflight request
},
AllowHeaders: []string{
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Headers",
"Content-Type",
"Content-Length",
"Accept-Encoding",
"Authorization",
},
AllowCredentials: true, // need cookie
MaxAge: 24 * time.Hour, // preflight request's result chache term
}))
// regist API endpoints
rg := r.Group("/api1")
// エンドポイント追加してく
r.Run(":8080")
}
欲張りな方向けにflaskも書いときます。
基本的にこういう許可リストは、別ファイルにまとめることが多い印象
from pathlib import Path
from flask import Flask
from flask_cors import CORS
app = Flask(__name__)
# add whitelist for CORS allow
# whilelist.txtに、許可するフロントのドメイン書いてあるものとする
with open(Path(__file__).resolve().parent.joinpath('../whitelist.txt'), 'r') as f:
CORS(app, origins=f.read().split('\n'), methods=['OPTIONS', 'GET', 'POST'])
# エンドポイントを登録とか
下手書きイメージ
app = Flask(__name__)
CORS(app,
origins=['http://localhost:8182', 'yourserver domain'],
methods=['OPTIONS', 'GET', 'POST'])
CPS
サーバ側が許可してないとCORSで弾かれますが
Webページ側がそもそも許可ドメイン以外へのアクセスを禁止することもできます。
JavaScriptには凶悪な、なんでもできてしまう関数 eval がありますので
攻撃者側のサーバがCORS許可してさえいれば
あらかじめ用意したスクリプトをサーバに用意し、Webページ上からjsでサーバへアクセスしeval
とできてしまいますので
Webページ上での任意js実行可能な状態を少しでも改善するすべとして、ContentSecurityPolicyを設定できます。
ヘッダで設定します。以下を参考
(補足) DDoS
やめましょう。
あんまり良くないですが、田代砲をご存知ですか?
某掲示板サイト発祥の、DoS攻撃用スクリプトです。
DoS程度なら簡単に再現できてしまうどころか、気付かぬうちにやっちゃうこともあります。
1つのPCからどこかのサーバへ、めっちゃ大量にリクエストを送って負荷をかけることをDoS攻撃と言いますが
1PC程度じゃ大した負荷になりませんので、複数PCを使って攻撃するバージョンのことをDDoSと言います。
DoS程度なら意図せずやってしまうことがあります。そう、JMeterならね。
負荷試験以外でも、組織契約のRedmineやGitアカウントに対してブラウザからページを大量に開きまくったり、送信ボタン連打しまくると割と落ちます。
落ちるというか、一時的に403 Forbiddenでアクセスを一定時間ブロックされることが多いです。
リクエスト数でクラウドを従量課金している場合等は本当に気をつけてください。
(補足) DMZ
そういったリクエスト、社内網を別ネットワークにしたらよくね?
ということで
外部からのリクエストはDMZで守り、内から外へはプロキシを介してブラックリストフィルタリングすることが大事。
DMZとは、外部から来たリクエストをスイッチングハブを介して内部に到達させない等をし、武装解除をさせるみたいな感じです。
結局こういったインフラ側での対策の上にAppGatewayやWAF、ネットワークセキュリティグループ等を儲け、何重にもブロックするのが通例です。
(補足) traceroute nslookup ssh root
アプリレイヤか定かでないですが、このサーバへのアクセスは
どういう経路?
行き先IPは?
どうやったら入れる?
等をコマンドで知ります。ざっくりまとめます
traceroute
windowsではtracertです。
経路を知れます。
nslookup
ドメインからIPを知ります。
ssh root
sshコマンドでサーバアクセス。
基本的にサーバは443と22を開けてると思います。
sshアクセス時にrootユーザのパスワードが雑魚だとまずいですね?
また、そもそもrootはsshでアクセスできないようにするのが通例です。
権限が最強なのにユーザIDがバレているからですね。
通例では /etc/ssh/sshd_config に対して
PermitRootLogin yes
を no にするような設定で塞げるはずです
まとめ
Webアプリ開発上
パスワードはハッシュ化+ストレッチングしよう、なるべくライブラリ使う
CSRF対策にcookieのsame-siteフラグStrict
ディレクトリトラバーサル対策と鍵ファイルは別場所で管理
XSSやSQL介入対策にサニタイズ
桁溢れ対策も加味したバリデーション
を注意し、フロント↔バック間はCORSポリシーがあるので
バックエンド側でPreflightリクエスト許可
フロントのドメインを許可しておく
よりXSS等に強くするために
CSPを設定し許可オリジン以外へのfetchやevalをブロック
また
サーバ側をいたわって、DDoSしない
インフラチームに感謝する
DMZ WAF等 低レイヤ含め何重にもブロックを設ける
インフラチームに感謝する
rootアクセス禁止
パスワードはちゃんと作る
Try Hack Meで遊んでみる
インフラチームに感謝する
インフラチームに感謝する
という感じです。
ALHについて知る
↓ ↓ ↓ 採用サイトはこちら ↓ ↓ ↓
↓ ↓ ↓ コーポレートサイトはこちら ↓ ↓ ↓
↓ ↓ ↓ もっとALHについて知りたい? ↓ ↓ ↓