🍄

脆弱性のあるページを作って、セキュアなページを作れるようになりたい

に公開

なんでこの記事を書いたのか?

現在、新卒として入社した会社の研修で、アプリケーションの開発に取り組んでいます。

例年の成果発表会では、先輩社員からいろいろと攻撃か仕掛けられるという噂を耳にしました(こわ〜)。

そこで、「攻撃されるなら、あえて脆弱性のあるコードを書いて、攻撃方法と対策を学んでみよう」と考え、この記事を書いてみました。

コードを公開しているので、もしよろしければ遊んでみてください。
https://github.com/zackerms/playground-ruby-security

自己紹介

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に送信されるの?

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

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">
  &lt;script&gt;alert("xss");&lt;/script&gt;
</div>

CSRF: Cross-Site Request Forgery

どんな攻撃か

  • 被害者のブラウザを騙して、被害者の認証情報を使った不正なリクエストを送信させる攻撃

根本的な原因

  • 本当に本人が正規のサイトから行ったリクエストであるかを検証できていないこと

どのように攻撃を受けるか

実際の流れを追うと、わかりやすいと思います。

登場人物

  • ユーザ
  • 被害者サーバ(私達が運用しているサービス)
  • 攻撃者のページ

正規のルートでログイン

  1. ユーザが被害者サーバの正規のページからログイン
  2. 被害者サーバは、
    • セッションIDを生成
    • セッションIDとユーザ情報を紐づける
    • Cookieに「セッションID」を設定

攻撃者のページに間違ってアクセス

  1. ユーザが攻撃者のページにアクセス
  2. 攻撃者のページは、被害者サーバにリクエストを送信(Cookie情報も一緒に送信される)
  3. 被害者サーバは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.rb
    server.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.rb
    server.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.rb
    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,
        }
        return session_id
      end
      # 中略
    end
    
    server.rb
    server.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.rb
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の設定

  • サーバへのプロキシ設定と、キャッシュに関する設定を記述します。
/etc/nginx/conf.d/default.conf
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;
    }
}
/etc/nginx/nginx.conf
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;
}

検証

http.js
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