本日0件のお問い合わせがありました。

✉️FIELDにお問い合わせ

システム開発アプリケーション開発

セキュリティ事故他社事例弊社が解決したケース

PHP2Shell のように世界中で一斉に対応が必要になる脆弱性は目立ちますが、日々の現場で起きるセキュリティ事故の多くは、もっと身近な設定や実装の見落としから始まります。

この記事では、弊社がご相談を受けて対応した事例をもとに、よくある問題と、実際に行った技術的な対策をまとめます。どれも特別な攻撃ではなく、少しの見直しで防げるものばかりです。ご自身のサイトやシステムの点検にお役立てください。


対策の一覧

#よくある問題弊社が行った主な対策
1WordPress のログインが総当たりにさらされているXML-RPC 無効化、ユーザー列挙の遮断、ログイン画面の二重認証、セッションと鍵の再発行
2エディタの画像アップロードがログインなしで使える認証必須化、拡張子の許可リスト、アップロード先での実行禁止
3並び替えや絞り込みの値を SQL に直接埋め込んでいるプレースホルダーでのバインド、値の許可リスト
4開発用の登録画面や DB 管理ツールが本番に残っている不要機能の削除、権限の既定値の見直し
5URL の ID を変えると他人のデータが見えるサーバー側での所有者チェック、ポリシーでの一元管理
6パスワード設定リンクのトークンが推測できるランダムなトークン、有効期限、使い捨て
7事故のあとに調べようとしてもログがないログの権限・保存期間・外部保管の見直し

1. WordPress のログインを総当たりから守る

よくある状態

  • xmlrpc.php が有効で、1回のリクエストで何度もパスワードを試せる
  • /wp-json/wp/v2/users や /?author=1 からユーザー名がわかる
  • 管理者のパスワードが短い、使い回している
  • ログイン画面に回数制限がない

この状態だと、ユーザー名を調べられたうえで、パスワードを自動で試され続けます。

実施した対策

XML-RPC とユーザー列挙を止める(mu-plugins に置くと、プラグインの停止で外れません)

<?php
// wp-content/mu-plugins/harden.php
add_filter('xmlrpc_enabled', '__return_false');

// 未ログインのユーザー一覧 REST を塞ぐ
add_filter('rest_endpoints', function ($endpoints) {
    if (!is_user_logged_in()) {
        unset($endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)']);
    }
    return $endpoints;
});

// ?author=N によるユーザー名の判明を防ぐ
add_action('template_redirect', function () {
    if (!is_admin() && isset($_GET['author'])) {
        wp_safe_redirect(home_url('/'), 301);
        exit;
    }
});

// 使っていなければアプリケーションパスワードも無効に
add_filter('wp_is_application_passwords_available', '__return_false');

ログイン画面を Web サーバー側でも守る(admin-ajax.php はフロントの機能で使うことがあるので除外します)

<Files "xmlrpc.php">
    Require all denied
</Files>

<Files "wp-login.php">
    AuthType Basic
    AuthName "Restricted"
    AuthUserFile /path/to/.htpasswd
    Require valid-user
</Files>

万一ログインされた後の掃除

  • 見覚えのない管理者ユーザーとアプリケーションパスワードの削除
  • 全ユーザーのセッションの破棄
  • wp-config.php のソルト(認証用キー)の再生成。盗まれたログイン Cookie が使えなくなります
  • 管理画面のファイル編集を止める:define('DISALLOW_FILE_EDIT', true);
  • テーマ内に置かれていた API キーや秘密鍵ファイルの失効と再発行

秘密鍵や API キーをテーマのフォルダに置いていた例もありました。管理者権限を取られた時点で、サイトから読めるものはすべて漏れたと考え、発行元で鍵を失効して作り直すことが必要です。

2. エディタの画像アップロードを守る

よくある状態

CKEditor の画像アップロード用 API や、KCFinder などのファイルブラウザーが、ログインしていなくても使える状態で公開されている例がありました。さらに、

  • 拒否する拡張子の一覧(ブラックリスト)で制御していて、.phar .php7 .pht などが漏れている
  • アップロード先のフォルダでスクリプトが実行できる

という条件が重なると、Web サーバー上で任意のプログラムを動かされる危険があります。

同じファイルブラウザーが公開されていても、Web サーバーが .php 以外を PHP として実行しない設定になっていたため、被害に至らなかった例もあります。一つの対策が漏れても、別の層で止まるようにしておくことが大切です。

実施した対策

認証を必須にする(Laravel の例)

Route::post('/ckeditor/image_upload', [CKEditorController::class, 'upload'])
    ->middleware('auth:admin');

拡張子は許可リストで判定し、中身も確認する

$allowed = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
$ext = strtolower($file->getClientOriginalExtension());

if (!in_array($ext, $allowed, true) || @getimagesize($file->getRealPath()) === false) {
    abort(422);
}

// 元のファイル名は使わず、ランダムな名前で保存
$name = Str::random(40) . '.' . $ext;
$file->storeAs('uploads/editor', $name, 'public');

ファイルブラウザーは既定で無効にし、管理者ログイン中だけ有効にする

アップロード先ではスクリプトを実行させない

# アップロード先の .htaccess
Options -ExecCGI
<FilesMatch "\.(php\d?|phar|pht|phtml|cgi|pl|py|sh)$">
    Require all denied
</FilesMatch>

nginx の場合は、アップロード先を PHP-FPM に渡さないようにします。

location ^~ /storage/uploads/ {
    location ~ \.(php\d?|phar|pht|phtml)$ { return 403; }
}

あわせて行ったこと

  • アップロード先にある見覚えのないファイルを公開領域の外に移し、調査用に保管
  • 公開フォルダに置きっぱなしだった、旧システムからの移行データの撤去
  • アプリの暗号化キー(Laravel の APP_KEY)や DB・メールのパスワードの変更

3. 並び替え・絞り込みの SQL インジェクションを防ぐ

よくある状態

フレームワークを使っていても、並び替えや条件の一部だけ生の SQL を書いているところに穴が残りやすいです。

// 危ない例:リクエストの値を SQL に直接埋め込んでいる
$query->orderByRaw("jobs.salary_type = {$request->salary_type} DESC");

検索画面はログインなしで使えることが多いので、自動ツールで大量に試され、DB の中身を読まれる原因になります。同じ書き方が、似た作りの別システムにも残っていたことがありました。

実施した対策

値はバインドし、型をそろえる

$query->orderByRaw('jobs.salary_type = ? DESC', [(int) $request->salary_type]);

カラム名や並び順のようにバインドできないものは、許可リストから選ぶ

$sortable = ['created_at', 'salary', 'id'];
$column = in_array($request->sort, $sortable, true) ? $request->sort : 'created_at';
$dir = $request->dir === 'asc' ? 'asc' : 'desc';

$query->orderBy($column, $dir);

コード全体を洗い出す

grep -rnE "(orderByRaw|whereRaw|havingRaw|selectRaw|DB::raw|DB::select)\(" app/

見つかった箇所のうち、変数を文字列に埋め込んでいるものはすべて書き直しました。

被害を小さくする設定

  • アプリの DB ユーザーを root から、そのアプリの DB だけを扱える専用ユーザーに変更
  • DB(3306)や Redis(6379)をインターネットに公開しない。Docker なら 127.0.0.1:3306:3306 のようにバインド先を限定する
  • 1ページあたりの件数に上限を付ける、検索 API にレート制限をかける

4. 本番に残った「便利機能」を片付ける

よくある状態

  • フレームワーク標準の会員登録画面(/register)が本番でも開いている
  • しかも、ユーザーの権限カラムの既定値が管理者になっている
  • adminer.php や phpinfo() を出すファイルが公開フォルダに残っている
  • ファイルの保存先(S3 など)が公開設定になっている
  • DB が、セキュリティグループで外部から接続できる状態になっている

これでは、誰でも管理者アカウントを作れたり、DB 管理画面にログインを試されたりします。

実施した対策

登録機能を止める(Laravel Fortify の例)

// config/fortify.php
'features' => [
    // Features::registration(),   ← 本番では使わない
    Features::resetPasswords(),
],

権限の既定値は「いちばん弱い権限」にする

// マイグレーション
$table->unsignedTinyInteger('role')->default(Role::VIEWER);

画面で隠すだけでなく、サーバー側で権限を確認する

Route::middleware(['auth', 'can:manage-users'])->group(function () {
    Route::resource('users', UserController::class);
});

公開フォルダの不要ファイルを確認する

find /var/www -maxdepth 3 \( -name "adminer*.php" -o -name "*info*.php" -o -name "*.bak" -o -name "*.sql" -o -name ".env*" \)

S3 は公開を止め、必要なファイルだけ署名付き URL で渡す

// 'public' を付けずに保存
Storage::disk('s3')->putFileAs('documents', $file, $name);

// 表示するときは期限付きの URL を発行
$url = Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(10));

バケットにはパブリックアクセスブロックを設定し、アクセスログも有効にします。

5. 他人のデータが見える(IDOR)を防ぐ

よくある状態

// 危ない例:ログインしていれば、ID を変えるだけで他社の応募情報が見える
public function show($id)
{
    return view('applications.show', ['application' => Application::findOrFail($id)]);
}

ログインしているかどうかは確認していても、そのデータが本人(自社)のものかどうかを確認していない状態です。URL の数字を一つ変えるだけで、ほかの会社の応募者の情報や履歴書 PDF が見えてしまいます。

実施した対策

検索の時点で自分のデータに絞る

public function show($id)
{
    $application = Application::where('company_id', auth()->user()->company_id)
        ->findOrFail($id);

    return view('applications.show', compact('application'));
}

ポリシーにまとめて、チェックの漏れを防ぐ

// app/Policies/ApplicationPolicy.php
public function view(User $user, Application $application): bool
{
    return $user->company_id === $application->company_id;
}

// コントローラー
$this->authorize('view', $application);

一括代入で権限を書き換えさせない

// 危ない:$user->fill($request->all());
$user->fill($request->only(['name', 'email', 'tel']));

PDF 生成の注意点

HTML から PDF を作る仕組み(wkhtmltopdf など)に、ユーザーが入力した文字をエスケープせずに渡すと、サーバー内のファイルを PDF に読み込まれる危険があります。

// テンプレートではエスケープする
{!! nl2br(e($resume->motivation)) !!}
// ローカルファイルを読ませない
$pdf->setOption('disable-local-file-access', true);

修正の確認には、別の会社のアカウントで同じ URL を開き、404 や 403 になるかを確かめるテストを用意しました。修正前のコードで同じテストを実行し、きちんと失敗する(穴を検出できる)ことも確かめています。

6. パスワード設定リンクを推測させない

よくある状態

  • 招待メールやパスワード設定のリンクに入っているトークンが、メールアドレスを base64 にしただけのもの
  • トークンが空のとき、条件が IS NULL になって既存ユーザーに一致してしまう
  • 本登録の処理でトークンを確認しておらず、ID を指定するだけで任意のアカウントのパスワードを設定できる
  • 一度使ったトークンが消えず、古いメールのリンクがいつまでも使える

実施した対策

// 発行:推測できない値を作り、DB にはハッシュで保存
$token = Str::random(64);
$user->forceFill([
    'invite_token_hash' => hash('sha256', $token),
    'invite_expires_at' => now()->addDays(3),
])->save();

// 確認:空の値を拒否し、一定時間で比較し、使ったら消す
if (!is_string($request->token) || $request->token === '') {
    abort(404);
}

$user = User::where('invite_expires_at', '>', now())
    ->findOrFail($request->user_id);

if (!hash_equals((string) $user->invite_token_hash, hash('sha256', $request->token))) {
    abort(404);
}

$user->forceFill([
    'password' => Hash::make($request->password),
    'invite_token_hash' => null,
    'invite_expires_at' => null,
])->save();

すでに送ってしまった古いリンクを無効にするため、登録済みユーザーに残っていたトークンをまとめて消す作業もあわせて行いました。パスワード設定ページには noindex を付け、検索エンジンにリンクが拾われないようにしています。

7. 事故のあとに調べられるよう、ログを残す

ここまでの事例では、ログが残っていなかったせいで、何を見られたのかを確定できないことが何度もありました。

  • ログフォルダの権限が合わず、Web サーバーがログを書けていなかった(ファイルが0バイトのまま)
  • ログの保存期間が短く、被害の時期のログがすでに消えていた
  • デプロイやコンテナの作り直しで、アプリのログが消えた
  • リバースプロキシや Docker 経由で、どのログにも本当のアクセス元 IP が残っていなかった

実施した対策

  • ログフォルダの所有者と権限を確認し、実際に書き込まれているかを確かめる(ls -l でサイズが増えているか)
  • logrotate の保存期間を延ばす(最低でも90日程度)
  • ログを CloudWatch Logs などサーバーの外に送る。サーバーを乗っ取られてもログは消されません
  • コンテナのログはボリュームか外部に出し、作り直しで消えないようにする
  • 信頼できるプロキシだけを登録し、X-Forwarded-For から本当のアクセス元 IP を記録する
  • 事故が起きたら、調査前にログとファイルの一覧(ハッシュ付き)を退避する

ログは、被害を受けていないことを証明するための材料でもあります。「見られていない」と言えるかどうかは、ログがあるかどうかで決まります。

事例から見える共通の教訓

  1. 本番に不要なものを置かない:登録画面、DB 管理ツール、確認用ファイル、移行データ、バックアップファイル
  2. 画面ではなくサーバーで確認する:ログインの有無だけでなく、権限と所有者まで確認する
  3. 入力は許可リストで扱う:拡張子、並び替えのカラム、更新してよい項目
  4. 一つの対策に頼らない:アプリの修正に加えて、Web サーバーの設定、ネットワーク、権限でも止める
  5. 漏れたら作り直す:管理者権限やファイルを取られたら、パスワード・API キー・暗号化キーはすべて再発行する
  6. ログを残す:調べられないことが、いちばん長引く被害になります

セルフチェックリスト

  • [ ] xmlrpc.php は無効か。WordPress のユーザー名は外から見えないか
  • [ ] 管理画面のログインに回数制限や追加の認証があるか
  • [ ] ファイルのアップロードはログイン必須か。拡張子は許可リストか。アップロード先でスクリプトは動かないか
  • [ ] SQL に変数を埋め込んでいる箇所はないか(orderByRaw や whereRaw などを検索)
  • [ ] 本番に /register や adminer.php などが残っていないか
  • [ ] ユーザーの権限の既定値は、いちばん弱い権限か
  • [ ] URL の ID を変えて、他人のデータが見えないか
  • [ ] パスワード設定リンクはランダムで、有効期限があり、使い捨てか
  • [ ] DB・Redis・ストレージはインターネットに公開されていないか
  • [ ] アクセスログは書き込まれているか。保存期間は十分か。サーバーの外にも残っているか

まとめ

ご紹介した事例は、どれも「特別に狙われた」わけではなく、インターネット上で自動的に探される、よくある穴でした。逆に言えば、ここに挙げた対策を一つずつ実施するだけで、多くの事故は防げます。

弊社では、こうした問題の調査(何が起きたのか、何を見られたのか)、緊急の封じ込め、コードとサーバー設定の修正、再発防止の仕組みづくりまでを一貫して行っています。「うちのシステムは大丈夫だろうか」と気になった方は、お気軽にご相談ください。

Yamamoto Yuya

プロフェッショナルとしての高いスキルと知識を持ち、誠実さと責任感を大切にする。常に向上心を持ち、新たな挑戦にも積極的に取り組む努力家。