전체 인프라 침해로 이어지는 두 개의 ‘Medium’ 수준 발견
Source: Dev.to
보안 발견을 분류하면서 백로그에 medium 수준의 항목들이 많이 보일 때 그 느낌을 아시나요? 그들은 결국—아마도—critical들, high들, 그리고 PM이 지난 세 스프린트 동안 계속 요청해 온 그 기능이 처리된 뒤에야 고쳐질 겁니다.
문제는 이렇습니다: 공격자는 심각도에 따라 분류하지 않습니다. 그들은 연결되는 것에 따라 분류합니다.
최근에 문서화한 취약점 체인을 살펴보고 싶습니다. 이 체인은 전혀 눈에 띄지 않는 두 개의 발견을 결합해 인증된 피싱과 Microsoft 365 환경에 대한 지속적인 접근을 가능하게 합니다.
두 발견만으로는 누구도 당황하지 않을 것입니다. 하지만 함께라면 완전한 침해가 됩니다.
발견 #1: 너무 많은 일을 하는 뉴스레터 엔드포인트
모든 웹 애플리케이션에는 이메일을 보내는 엔드포인트가 있습니다—뉴스레터 가입, 연락 양식, 비밀번호 재설정, 트랜잭션 알림 등.
이러한 엔드포인트는 기능을 수행하기 위해 공개되어야 합니다(그게 목적이죠), 하지만 남용을 방지하기 위해 엄격한 입력 검증이 필요합니다.
취약한 패턴
POST /api/newsletter/subscribe HTTP/1.1
Content-Type: application/json
{
"recipient": "victim@target.com",
"subject": "Urgent: Security Alert",
"body": "...phishing content..."
}
인증 없음. 임의의 recipient, subject, 그리고 HTML body.
이 요청이 처리되면, 애플리케이션은 조직의 정식 메일 인프라를 통해 이메일을 보냅니다. 이메일은 적절한 인증이 된 승인된 메일함에서 발송됩니다.
실제 의미
- 이메일이 SPF, DKIM, DMARC 검사를 통과합니다
- 발신자는 조직의 공식 이메일 주소를 표시합니다
- Gmail은 정식 발신원이라서 자동으로 “중요”로 태그합니다
- 스팸이 아니라 기본 받은편지함에 도착합니다
이제 대상의 자체 인프라를 피싱 플랫폼으로 전락시킨 것입니다.
이러한 엔드포인트 찾기는 어렵지 않다
site:target.com newsletter
site:target.com "sign up"
site:target.com contact
주 메뉴에 링크되지 않은 페이지도 종종 인덱싱되어 완전히 동작합니다.
Finding #2: Error Messages That Leak Tokens
두 번째 발견은 프로덕션 환경에서의 상세 오류 처리와 관련됩니다. 이전에 본 패턴입니다:
POST /api/newsletter/subscribe
Content-Type: application/json
{
"recipient": "test@test.com"
// missing required fields
}
Response
{
"error": "ValidationError",
"stack": "...",
"context": {
"oauth_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"service": "graph.microsoft.com",
...
}
}
왜 오류 응답에 OAuth 토큰이 포함될까요?
많은 애플리케이션에서 내부 서비스는 애플리케이션 컨텍스트에 저장된 토큰을 사용해 서로 인증합니다. 상세 오류 처리는 그 컨텍스트를 클라이언트에 그대로 출력하게 되며, 이 과정에서 토큰이 의도치 않게 노출됩니다.
이 경우 유출된 토큰은 Microsoft Graph API용이었습니다.
토큰의 범위에 따라 다음과 같은 접근 권한을 부여할 수 있습니다:
- 메일 (읽기 & 보내기)
- 캘린더
- Teams 대화
- SharePoint 및 OneDrive 파일
- 사용자 디렉터리 및 조직도
- 경우에 따라 Azure 리소스 및 Intune
“하지만 토큰은 한 시간 안에 만료되죠.”
맞습니다. 하지만 오류를 다시 일으키면 새 토큰을 바로 얻을 수 있습니다. 이 취약점은 토큰 디스펜서가 되어, 자격 증명 없이도 지속적인 접근을 가능하게 합니다.

체인
단계 1: 토큰 추출
공격자는 상세 오류 조건을 찾아 유효한 Graph 토큰을 추출하고, 이제 로그인 실패 알림을 트리거하지 않고 인증된 M365 접근 권한을 확보합니다.
단계 2: 정찰
토큰을 사용해 다음을 열거합니다:
# Get org chart
GET https://graph.microsoft.com/v1.0/users
# Get user details
GET https://graph.microsoft.com/v1.0/users/{id}
# Get manager chain
GET https://graph.microsoft.com/v1.0/users/{id}/manager
직원 이름, 직함, 프로젝트, 보고 구조, 내부 용어 등—설득력 있는 피싱을 만들기 위해 필요한 모든 정보.
단계 3: 표적 피싱
그들은 이메일 엔드포인트를 사용해 피싱 캠페인을 보냅니다. 이는 일반적인 “계정을 확인하려면 여기를 클릭하세요” 이메일이 아니라 실제 프로젝트 이름, 정확한 조직 구조, 내부 용어를 사용해 제작되며, 조직 자체 메일 서버에서 발송됩니다.
단계 4: 권한 상승
수집된 자격 증명으로 공격자는 더 깊이 침투합니다—관리자 계정, Azure 리소스, 프로덕션 인프라 등.

단계 5: 지속성
상세 오류가 존재하는 한, 공격자는 토큰을 재생성할 수 있습니다. 자격 증명 회전은 도움이 되지 않으며, 취약점 자체가 지속성 메커니즘입니다.
해결 방안
이메일 엔드포인트
# Bad: accepts arbitrary input
@app.post("/api/newsletter/subscribe")
def subscribe(data: dict):
send_email(
to=data.get("recipient"),
subject=data.get("subject"),
body=data.get("body")
)
# Good: strict schema, single purpose
class SubscribeRequest(BaseModel):
email: EmailStr
@app.post("/api/newsletter/subscribe")
def subscribe(request: SubscribeRequest):
# Only allow sending a predefined template to the supplied email
send_email(
to=request.email,
subject="Thank you for subscribing!",
body=render_template("newsletter_welcome.html")
)
- Validate 입력을 엄격한 스키마(Pydantic 등)로 검증합니다.
- Restrict 엔드포인트를 단일, 비구성 가능한 목적(예: 고정 템플릿만 전송)으로 제한합니다.
- Authenticate 내부 호출자를 가능하면 인증하고, 그렇지 않으면 속도 제한 및 로깅을 적용합니다.
상세 오류 처리
- Never expose 오류 응답에 내부 컨텍스트(특히 토큰)를 노출하지 않습니다.
- 클라이언트에 generic error message(일반 오류 메시지)를 반환하고, 상세 스택 트레이스를 server‑side(서버 측)에서 기록합니다.
- 토큰을 주기적으로 교체하고 short‑lived(단기간) 토큰을 사용하며, least‑privilege scopes(최소 권한 범위)를 적용합니다.
- 응답을 정제하는 centralized error‑handling middleware(중앙 집중식 오류 처리 미들웨어)를 구현합니다.
TL;DR
- Lock down public email‑sending endpoints – 엄격한 스키마, 고정 템플릿, 인증, 속도 제한을 적용합니다.
- Sanitize error responses – 토큰이나 내부 컨텍스트가 절대 유출되지 않도록 합니다.
- Monitor 비정상적인 이메일 전송 활동 및 토큰 유출 패턴을 모니터링합니다.
두 가지 발견을 모두 해결함으로써 공격 체인을 차단하고, 공격자가 귀하의 인프라를 피싱 플랫폼으로 전환하는 능력을 제거합니다.
예시: 구독 엔드포인트
@app.post("/subscribe")
def subscribe(data: SubscribeRequest):
add_to_mailing_list(data.email)
send_confirmation_email(data.email) # fixed template
뉴스레터 가입인 경우, 이메일 주소만 받습니다—그게 전부입니다.
오류 처리
# Bad: dumps everything to client
@app.exception_handler(Exception)
def handle_error(request, exc):
return JSONResponse({
"error": str(exc),
"stack": traceback.format_exc(),
"context": app.state.__dict__ # tokens live here
})
# Good: generic client response, detailed server logs
@app.exception_handler(Exception)
def handle_error(request, exc):
logger.error(f"Error: {exc}", exc_info=True) # server‑side only
return JSONResponse({
"error": "An error occurred",
"request_id": generate_request_id()
})
프로덕션에서는 절대 스택 트레이스나 애플리케이션 컨텍스트를 클라이언트에 반환해서는 안 됩니다.
요약
두 가지 중간 정도 심각도 발견 사항:
- 하나의 엔드포인트가 너무 많은 매개변수를 받아들입니다.
- 다른 엔드포인트가 과도한 정보를 반환합니다.
귀하의 취약점 스캐너는 이를 개별적으로 평가하고 적절히 등급을 매겼습니다. 스캐너가 틀린 것은 아니지만, 공격자처럼 생각하지는 못합니다.
공격자는 심각도 등급에 신경 쓰지 않습니다. 그들은 경로에 신경 씁니다.