脆弱性のあるページを作って、セキュアなページを作れるようになりたい
なんでこの記事を書いたのか?
現在、新卒として入社した会社の研修で、アプリケーションの開発に取り組んでいます。
例年の成果発表会では、先輩社員からいろいろと攻撃か仕掛けられるという噂を耳にしました(こわ〜)。
そこで、「攻撃されるなら、あえて脆弱性のあるコードを書いて、攻撃方法と対策を学んでみよう」と考え、この記事を書いてみました。
コードを公開しているので、もしよろしければ遊んでみてください。
自己紹介
zacker(ざっかー)といいます。
「komichi」という2時間くらいの暇つぶしプランを作ってくれるサービスを作っています。
ぜひ、一度触ってみてください!
https://komichi.app/ (Android版, iOS版)
どんな脆弱性を扱うか
- SQL Injection
- XSS
- CSRF
- DoS(これは脆弱性ではないかもしれませんが、攻撃の手段として用いられるので対策を考えます)
SQL Injection
どんな攻撃か
- URLのパラメータ等に悪意のあるSQLコードを入力し、データベースを不正に操作する攻撃
根本的な原因
- ユーザから渡されたパラメータをエスケープしていない
- => 任意のクエリが実行されてしまう
脆弱性のあるコード
username = req.query['username']
# 脆弱性:ユーザから渡されたパラメータを、そのまま埋め込んでいる
query = "SELECT username FROM users WHERE username = '#{username}' AND role = 'user'"
results = client.query(query)
どのように攻撃をうけるか
-
通常の挙動
パラメータに名前を入力することで、その名前に対応するユーザを探します。
/search?username=alice
実行されるクエリSELECT * FROM users WHERE username = 'alice' AND role = 'user' -- => [{"username"=>"alice"}]
-
全ユーザ表示
必ず満たすような条件を入力します。
テンプレートに含まれる
''
(引用符)がちゃんと閉じるように工夫するのがミソです。/search?username=' OR '1 '= '1
実行されるクエリSELECT * FROM users WHERE username = '' OR '1 '= '1' AND role = 'user' -- => [{"username"=>"alice"}, {"username"=>"bob"}, {"username"=>"charlie"}, {"username"=>"eve"}]
-
管理者を含む全ユーザを表示
必ず満たす条件を入力するという点は、全ユーザ表示と変わりません。
その上で、後ろの条件文をキャンセルできるように、コメントアウト(
--
)を入れます。/search?username=' OR role = 'admin' OR '1' = '1
実行されるクエリSELECT * FROM users WHERE username = '' OR role = 'admin' OR '1' = '1' AND role = 'user' -- => [{"username"=>"admin"}, {"username"=>"alice"}, {"username"=>"bob"}, {"username"=>"charlie"}, {"username"=>"eve"}]
攻撃をしてみて初めて気がついたのですが、コメントアウトを入れるときには必ず空白が必要となります
# OK --␣ # NG --
-
メタデータの取得
テーブル名を取得してみます。
UNION SELECT
というクエリを利用することで、追加のSELECT文を動作させることができます。/search?username=' UNION SELECT table_name FROM information_schema.columns WHERE table_schema=database() --
実行されるクエリSELECT * FROM users WHERE username = '' UNION SELECT table_name FROM information_schema.columns WHERE table_schema=database() -- ' AND role = 'user' -- => [{"username"=>"users"}]
オリジナルのSELECT文で取得する列の数(今回は
username
の一列だけ)と、UNION SELECT
で取得する列数が一致しない場合はエラーとなります。
対策方法
- クエリとパラメータを分離する(Prepared Statementsを使用する)
- イメージとしては、「ここは命令文(DBの検索機能が動作する部分)だよ」、「ここはパラメータだよ」というのを分けてあげるということだと思います。
username = req.query['username']
stmt = client.prepare("SELECT username FROM users WHERE username = ? AND role = 'user'")
results = stmt.execute(username)
実際にはどんなリクエストがDBに送信されるの?
MySQLのドキュメントを読んでみると、「Prepared」「EXECUTE」というクエリがあり、別々に送信されるみたいです。
mysql> PREPARE stmt1 FROM 'SELECT SQRT(POW(?,2) + POW(?,2)) AS hypotenuse';
mysql> SET @a = 3;
mysql> SET @b = 4;
mysql> EXECUTE stmt1 USING @a, @b;
+------------+
| hypotenuse |
+------------+
| 5 |
+------------+
mysql> DEALLOCATE PREPARE stmt1;
XSS: Cross-Site Scripting
どんな攻撃か
- 悪意のあるスクリプトをWebサイトに入力して、他のユーザーのブラウザで実行させる攻撃
- 例:ユーザの
<p>xss</p>
という投稿が、文字列としてではなく、HTMLとして表示されてしまう
根本的な原因
- ユーザー入力がエスケープされずにHTMLとして表示されてしまうこと
脆弱性のあるコード
-
保存時の脆弱性
message = req.query['message'] # XSS脆弱性: ユーザー入力を無害化せずに直接データベースに保存 client.query("INSERT INTO messages (message) VALUES ('#{message}')")
-
描画時の脆弱性
<% messages.each do |message| %> <div class="message"> <!-- XSS脆弱性: ERBでユーザー入力をエスケープせずに出力 --> <%= message['message'] %> </div> <% end %>
どのように攻撃を受けるか
-
pタグを入れてみる
フォームに以下のような値を入力し、送信してみます。<p style="color: red">xss</p>
無事、HTMLとして認識された状態で表示されました。
-
scriptタグを入れてみる
方法はp
タグのときと同様です。<script>alert("xss");</script>
対策方法
- 描画/保存時に値をエスケープする
<!-- ERB の場合 -->
<%= CGI.escapeHTML(message['message']) %>
描画される状態: HTMLではなく、文字列として描画されます。
<div class="message">
<script>alert("xss");</script>
</div>
CSRF: Cross-Site Request Forgery
どんな攻撃か
- 被害者のブラウザを騙して、被害者の認証情報を使った不正なリクエストを送信させる攻撃
根本的な原因
- 本当に本人が正規のサイトから行ったリクエストであるかを検証できていないこと
どのように攻撃を受けるか
実際の流れを追うと、わかりやすいと思います。
登場人物
- ユーザ
- 被害者サーバ(私達が運用しているサービス)
- 攻撃者のページ
正規のルートでログイン
- ユーザが被害者サーバの正規のページからログイン
- 被害者サーバは、
- セッションIDを生成
- セッションIDとユーザ情報を紐づける
- Cookieに「セッションID」を設定
攻撃者のページに間違ってアクセス
- ユーザが攻撃者のページにアクセス
- 攻撃者のページは、被害者サーバにリクエストを送信(Cookie情報も一緒に送信される)
- 被害者サーバはCookieに含まれる「セッションID」からユーザを判別し、ユーザ情報を更新する
脆弱性のあるコード
被害者サーバ
-
セッション管理
server.rb# セッション(セッションIDとユーザ情報の関連を管理) class Session @@sessions = {} def self.create(user_id) session_id = SecureRandom.hex(16) # トークンを付与 csrf_token = SecureRandom.hex(32) @@sessions[session_id] = { user_id: user_id, } return session_id end def self.get(session_id) @@sessions[session_id] end # 中略 end
-
ログイン
ログインが正しい場合、セッションを作成し、Cookieに保存します。
server.rbserver.mount_proc('/login') do |req, res| # ログイン情報を検証 username = req.query['username'] password = req.query['password'] user = client.query("SELECT * FROM users WHERE username = '#{username}' AND password = '#{password}'").first # セッションを新規作成し、Cookieに設定 session_id = Session.create(user['id']) res.cookies << WEBrick::Cookie.new('session_id', session_id) res.set_redirect(WEBrick::HTTPStatus::Found, '/profile') end
-
ユーザ情報を更新
Cookieに保存されたセッションIDに紐づくユーザの情報を更新します。
server.rbserver.mount_proc('/update_email') do |req, res| # セッション情報を取得 session_id = req.cookies.find { |c| c.name == 'session_id' }&.value session = Session.get(session_id) user_id = session[:user_id] # データベース更新 new_email = req.query['email'] client.query("UPDATE users SET email = '#{new_email}' WHERE id = #{user_id}") res.set_redirect(WEBrick::HTTPStatus::Found, '/profile?updated=1') end
攻撃者のページ
-
/update_email
に攻撃者のメールアドレスを仕込んだフォームリクエストを送信-
hacked@evil.com
という値が送信される
-
-
iframe
を埋め込むことで、フォーム送信時に画面遷移しないようにする
<!-- 隠しCSRF攻撃フォーム - 自動的に送信される -->
<iframe style="display:none" name="hidden-frame"></iframe>
<form id="csrf-form" action="http://localhost:3000/update_email" method="post" target="hidden-frame">
<input type="hidden" name="email" value="hacked@evil.com">
</form>
<script>
// ページ読み込み時に自動的にフォームを送信
window.onload = function() {
document.getElementById('csrf-form').submit();
};
</script>
対策方法
-
CSRFトークンを利用する
-
ログイン時にCSRFトークンを生成し、セッションと紐づける
server.rbclass Session @@sessions = {} def self.create(user_id) session_id = SecureRandom.hex(16) # トークンを付与 csrf_token = SecureRandom.hex(32) @@sessions[session_id] = { user_id: user_id, csrf_token: csrf_token, } return session_id end # 中略 end
server.rbserver.mount_proc('/login') do |req, res| username = req.query['username'] password = req.query['password'] user = client.query("SELECT * FROM users WHERE username = '#{username}' AND password = '#{password}'").first # セッションとCSRFトークンを作成 session_id = Session.create(user['id']) res.cookies << WEBrick::Cookie.new('session_id', session_id) res.set_redirect(WEBrick::HTTPStatus::Found, '/profile') end
-
フォームリクエストに含まれるCSRFトークンを検証する
server.rb# セッション情報を取得 session_id = req.cookies.find { |c| c.name == 'session_id' }&.value session = Session.get(session_id) # フォームからCSRFトークンを取得 -> 検証 #(セッションIDに紐づく、正しいCSRFトークンか) csrf_token = req.query['csrf_token'] if !csrf_token || csrf_token != session[:csrf_token] res.status = WEBrick::HTTPStatus::Forbidden res.body = "CSRF token mismatch" next end
-
フォームにCSRFトークンを埋め込む
ログインしたあとにだけ見られる「プロフィール編集画面」などにトークンを埋め込みます。ログインユーザとCSRFトークンは一対一対応で、ログインユーザしか見ることができません(これ重要)
<input type="hidden" name="csrf_token" value="<%= session[:csrf_token] %>">
なぜ、CSRFトークンを使うと対策できるの?
- A:CSRFトークンを攻撃者が知ることができないため
今まで攻撃が可能だったのは、以下の2点が原因だと考えられます
- 誰でもフォームリクエストを送信できる
- 攻撃者のサイトからのリクエストでも、ユーザーのCookieが送られる
そして、今回の対策では以下のことを行いました。
- CSRFトークンを使って、「本当にユーザのリクエストか」を判定する
- CSRFトークンはログイン済みユーザーだけが見られるページに表示される
- → 攻撃者はこのトークンを見ることができない
- → トークンなしの攻撃者のリクエストは拒否される
ユーザのログイン状態でページを表示すれば、CSRFトークンを盗めるのでは
-
iframe
を利用すると、ユーザが認証済みの状態でページが表示されます。 - この
iframe
内のCSRFトークンを攻撃者ページのJavaScriptで取得できるのではないかと考えました。
結論
「他のサイトのJavascriptから、DOMを操作できないようにする」(Same-Origin Policy)というブラウザの機能があるから大丈夫
検証
-
正規のページからCSRFトークンを取得
=> 取得できます。document.querySelector('input[name="csrf_token"]').value; // > 'eae179f009c512f79772367cc25cb954224adef6f454985c7ec178d0949a35b4'
-
攻撃者のページから、CSRFトークンを取得
- iframeに正規のページを表示し、その内容を取得しようとします。
- しかし、
iframe
の内部要素にJavaScriptからアクセスしようとするとnull
になるため、失敗します。
<iframe id="victimFrame" src="http://localhost:3000/profile"></iframe> <script> setTimeout(function() { // 失敗する // `document.getElementById('victimFrame').contentDocument`が`null`になります。 var token = document.getElementById('victimFrame').contentDocument.querySelector('input[name="csrf_token"]').value; console.log("盗んだトークン: " + token); }, 1000); </script>
DoS: Denial of Service attack
どんな攻撃か
- 大量のリクエストを送りつけてリソースを枯渇させ、正規ユーザーへのサービス提供を妨害する攻撃
根本的な原因:
- リクエスト処理が非効率的、レート制限がない、などなど
- 今回は効率的にリクエストの処理をできるような方法を考えます。
どのように攻撃を受けるか
- 攻撃方法はシンプルで、サーバに大量のリクエストを送りつけます。
対策方法
レスポンスを効率的に行うために、キャッシュを活用していきます。
以下のようにNginxをプロキシとして配置し、ユーザにキャッシュを返すようにします。
このようにすることで、サーバは何もしなくても、前に出力した結果をNginxが代わりにユーザに渡してくれます。
サーバ側のコード
画像を返す簡単な機能を実装します。
server.mount_proc '/' do |req, res|
res.body = File.read('public/rails.png')
res.content_type = 'image/png'
# キャッシュに関する設定
res.header['Cache-Control'] = 'max-age=3600, public' # 1時間キャッシュする
res.header['Etag'] = Digest::MD5.hexdigest(res.body) # ファイルが変更されたかを検証するため
res.header['Last-Modified'] = File.mtime('public/rails.png').httpdate
end
Nginxの設定
- サーバへのプロキシ設定と、キャッシュに関する設定を記述します。
server {
listen 80;
server_name localhost;
location / {
# バックエンドサーバーへのプロキシ
proxy_pass http://web-image:3000;
# my_cacheというキャッシュゾーンを使用
proxy_cache my_cache;
# 200 OKレスポンスを60分間キャッシュ
proxy_cache_valid 200 60m;
}
# 静的ファイル(画像、CSS、JavaScript)に対する設定
location ~* \.(jpg|jpeg|png|gif|css|js)$ {
# 静的ファイルの配置場所
root /app/public;
# ブラウザキャッシュの有効期限を30日に設定
expires 30d;
}
}
user nginx;
worker_processes auto;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
# プロキシキャッシュの設定
# /var/cache/nginx: キャッシュファイルの保存場所
# keys_zone=my_cache:10m: メタデータ用に10MBのメモリを確保
proxy_cache_path /var/cache/nginx keys_zone=my_cache:10m;
include /etc/nginx/conf.d/*.conf;
}
検証
- 今回はDoSではありませんが、短期間に大量のユーザがアクセスすることを想定します。
- k6という負荷テストを行うツールを利用します。
import http from 'k6/http';
import { sleep, check } from 'k6';
import { Counter } from 'k6/metrics';
const failedRequests = new Counter('failed_requests');
const successfulRequests = new Counter('successful_requests');
// 負荷の設定とテスト内容
export const options = {
stages: [
{ duration: '10s', target: 100 }, // 10秒かけて、ユーザー数を100に増加
{ duration: '10s', target: 500 }, // 10秒かけて、ユーザー数を500に増加
{ duration: '10s', target: 1000 }, // 10秒かけて、ユーザー数を1000に増加
],
timeout: '5s',
thresholds: {
// 10%未満の失敗率
http_req_failed: ['rate<0.1'],
// 95%のリクエストが1.5秒以内に完了すること
http_req_duration: ['p(95)<1500'],
// 失敗リクエスト数が500未満であること
failed_requests: ['count<500'],
},
};
// リクエスト内容
export default function () {
const endpoint = __ENV.ENDPOINT || "http://nginx:3000/";
const res = http.get(endpoint, { timeout: '5s'});
check(res, {
'status is 200': (r) => r.status === 200,
'response time is acceptable': (r) => r.timings.duration < 500,
});
}
以下の環境でテストします。
項目 | 値 |
---|---|
機種 | MacBook Air(2020) |
チップ | Apple M1 |
メモリ | 16GB |
サーバに直接リクエストを送信
400ユーザあたりからリクエストが落ち始めました。
メモリはあまり使用していないようですが、CPUがボトルネックになっているようです。
Nginxを介してリクエスト
エラーなく対処することができました。
サーバーには最初の一度だけリクエストが飛びます。
リソースに制約をつけ、Nginxを介してリクエスト
nginxとサーバを起動している2つのDockerコンテナに、以下のような制約をかけます。
項目 | 値 |
---|---|
RAM上限 | 256MB |
CPU利用上限 | 30% |
このような制限をつけても、レスポンス時間が長くなっていますが、捌き切ることができました。
キャッシュが利用できる場合は、CPUもメモリもそこまで使わないようです。
おわりに
いろいろな攻撃方法について試してみたので、長くなってしまいました。
SQL Injectionでコメントアウトをさせるには空白を付ける必要がある(--
)など、実際にやらないと分からない発見もあったので、かなり身になったと実感しています。
Discussion