☰
jena.run
/
♡

관리자 페이지는 멀쩡했는데 API가 권한검사를 안 했다

GraphQL 하나 때문에 SaaS의 Tenant Isolation이 무너진 모의해킹

관리자 페이지는 멀쩡했는데 API가 권한검사를 안 했다

— GraphQL 하나 때문에 SaaS의 Tenant Isolation이 무너진 모의해킹

웹 모의해킹을 하다 보면 의외로 이런 서비스를 자주 만난다.

프론트엔드는 꽤 잘 만들어져 있다.

관리자 메뉴도 권한별로 잘 숨겨져 있고,

URL을 직접 입력해도 접근이 막힌다.

/admin

403 Forbidden

세션 처리도 정상.

JWT도 딱히 이상 없어 보인다.

CORS도 멀쩡하다.

SQL Injection도 안 나온다.

Scanner도 별다른 걸 못 찾는다.

처음 몇 시간은 이런 생각이 든다.

"생각보다 잘 만들었는데?"

그런데 이런 서비스에서 내가 가장 먼저 믿지 않는 게 하나 있다.

화면이다.


UI에서 안 보인다고 권한이 없는 것은 아니다

이번 테스트 대상은 전형적인 B2B SaaS라고 가정해보자.

하나의 서비스 안에 여러 기업 고객이 존재한다.

SaaS Platform

├── Tenant A
│   ├── Admin
│   └── Users
│
├── Tenant B
│   ├── Admin
│   └── Users
│
└── Tenant C
    ├── Admin
    └── Users

보안에서 중요한 조건은 단순하다.

Tenant A 사용자는 절대로 Tenant B의 데이터를 볼 수 없어야 한다.

이걸 흔히 tenant isolation이라고 한다.

Enterprise SaaS라면 가장 중요한 security boundary 중 하나다.


테스트 계정부터 두 개 만든다

Access Control 테스트에서 계정 하나만 가지고 테스트하는 건 별로 좋은 방법이 아니다.

최소 두 개는 준비한다.

Account A
Tenant: ACME
Role: User

Account B
Tenant: Globex
Role: User

가능하면 role도 여러 개 만든다.

ACME_ADMIN
ACME_USER
GLOBEX_ADMIN
GLOBEX_USER

이렇게 만들어두면 테스트하기 훨씬 편하다.

왜냐하면 Access Control 테스트의 본질은 결국

Same Request
Different Identity
Different Result

가 되어야 하기 때문이다.


Burp에서 로그인 흐름을 보다가 GraphQL이 보였다

웹을 평범하게 돌아다니면서 Burp HTTP History를 본다.

그러다 이런 요청이 하나 잡힌다.

POST /graphql HTTP/2
Host: api.example.com
Authorization: Bearer eyJ...

Content-Type: application/json

body를 보면 GraphQL이다.

{
  "query": "query GetProfile { me { id name email organization { id name } } }"
}

여기까지는 아무 문제 없다.

PortSwigger도 GraphQL API 테스트 시 웹 인터페이스를 직접 사용하면서 Proxy history에서 실제 query와 mutation을 확인하는 방식을 기본적인 접근법으로 설명한다.

몇 개 요청을 더 보니 재미있는 mutation이 나타난다.

mutation UpdateProject($id: ID!, $name: String!) {
  updateProject(id: $id, name: $name) {
    id
    name
  }
}

variables는 이런 형태다.

{
  "id": "proj_8f92ad",
  "name": "Security Review"
}

여기서 바로 궁금해지는 게 하나 있다.

서버는 id가 내 프로젝트인지 확인할까?


Access Control에서는 parameter를 믿지 않는다

개발자 입장에서 이 request는 자연스럽다.

Authenticated User
       |
       v
Project ID
       |
       v
Update Project

하지만 보안 관점에서는 중간에 하나가 빠져 있다.

Authenticated User
       |
       v
Project ID
       |
       v
Ownership Check
       |
       v
Authorization Check
       |
       v
Update Project

그래서 Account A에서 프로젝트 하나를 만든다.

Account A
Project ID: proj_A111

Account B에서도 하나 만든다.

Account B
Project ID: proj_B222

이제 Account A의 token으로 Account B의 project ID를 참조했을 때 서버가 어떻게 반응하는지만 확인하면 된다.

정상적인 결과는 당연히 이래야 한다.

403 Forbidden

혹은 존재 자체를 숨기기 위해

404 Not Found

도 충분히 가능하다.

중요한 것은 작업이 수행되지 않아야 한다는 것이다.


그런데 응답이 200이었다

테스트 결과가 이런 식으로 돌아왔다.

{
  "data": {
    "updateProject": {
      "id": "proj_B222",
      "name": "Security Review"
    }
  }
}

처음 발견하면 꽤 싱거워 보인다.

그냥 IDOR 아닌가?

맞다.

REST에서는 흔히 IDOR이라고 부르고, API 보안에서는 BOLA(Broken Object Level Authorization)라는 표현도 많이 사용한다.

GraphQL에서도 object ID 등의 argument를 통해 직접 객체에 접근하면서 적절한 권한 검사를 하지 않는 경우 이런 access-control 문제가 발생할 수 있다.

그런데 Senior 레벨에서는 여기서 바로

"Critical!"

하고 보고서를 쓰면 안 된다.

이제부터가 실제 테스트다.


먼저 데이터 한 건만 검증한다

실서비스 모의해킹에서 가장 중요한 원칙 중 하나.

취약점을 증명하는 데 필요한 만큼만 한다.

다른 고객의 데이터를 10,000개 가져올 수 있다고 해서 실제로 10,000개 가져올 필요는 없다.

1 Object
   |
   v
Proof Established
   |
   v
Stop

가능하면 테스트용 tenant를 사용한다.

그래서 테스트 계정 두 개로만 재현한다.

Tenant A
    |
    | Unauthorized Request
    v
Tenant B Test Object

그리고 증거를 남긴다.

  • Request

  • Response

  • Timestamp

  • Account

  • Role

  • Object owner

  • Expected behavior

  • Actual behavior

이 정도면 충분하다.


그런데 조회만 되는 게 아니었다

다른 mutation도 확인한다.

mutation DeleteProject($id: ID!) {
  deleteProject(id: $id)
}

여기도 동일한 authorization 문제가 있다면 이야기가 달라진다.

Read Other Tenant Data
        |
        v
Modify Other Tenant Data
        |
        v
Delete Other Tenant Data

단순 정보 노출에서 cross-tenant write access로 올라간다.

하지만 실서비스 데이터를 실제로 삭제하면 안 된다.

그래서 Tenant B에 테스트용 object를 하나 만든다.

Name: PENTEST_DELETE_ME

그 object 하나로만 검증한다.

이게 모의해킹에서 생각보다 중요하다.

취약점을 많이 이용하는 게 실력이 아니다.

최소한의 impact로 최대한 정확하게 증명하는 게 실력에 가깝다.


여기서 API 전체를 다시 본다

하나의 resolver에서 authorization이 빠졌다는 건 다른 resolver도 같은 pattern일 가능성이 있다는 뜻이다.

그래서 비슷한 operation들을 찾는다.

project(id)
document(id)
invoice(id)
member(id)
workspace(id)
apiKey(id)
integration(id)

그리고 mutation도 본다.

updateProject
deleteProject
inviteMember
changeRole
regenerateKey
disconnectIntegration

하지만 전부 무작정 실행하는 게 아니다.

먼저 authorization matrix를 만든다.

                 Own    Same Tenant    Other Tenant

Read             Yes        ?              ?
Update           Yes        ?              ?
Delete           Yes        ?              ?
Invite           Yes        ?              ?
Change Role       ?         ?              ?

Senior pentest에서 이런 표 하나가 꽤 유용하다.

취약점 하나 찾는 게 아니라 권한 모델 자체를 검사할 수 있기 때문이다.


진짜 위험한 건 IDOR 자체가 아니었다

그러다 inviteMember mutation을 본다.

mutation InviteMember(
  $organizationId: ID!,
  $email: String!,
  $role: Role!
) {
  inviteMember(
    organizationId: $organizationId,
    email: $email,
    role: $role
  ) {
    id
  }
}

여기서 중요한 파라미터는 세 개다.

organizationId
email
role

그리고 이런 생각이 든다.

organizationId도 사용자가 직접 보내는데?

Account A가 Tenant B의 organizationId를 넣어본다.

테스트 환경에서만.

응답이 성공한다.

그리고 Tenant B의 테스트 관리자 계정에서 member list를 확인한다.

Account A가 초대한 계정이 보인다.

여기서 취약점의 성격이 완전히 바뀐다.

BOLA
  |
  v
Cross-Tenant Invitation
  |
  v
Unauthorized Membership

그리고 Role 값을 본다

프론트엔드에서는 일반 사용자가 초대할 때 role을 선택할 수 없게 되어 있었다.

UI에서는 자동으로

MEMBER

만 들어간다.

그런데 request에는 role이 존재한다.

그래서 permitted test environment에서 authorization behavior만 확인한다.

MEMBER
ADMIN
OWNER

여기서 서버가 client input을 그대로 신뢰하면 두 번째 문제가 생긴다.

Broken Object Authorization
        +
Broken Function Authorization
        |
        v
Cross-Tenant Privilege Escalation

이제 보고서 제목을 단순히

IDOR in GraphQL API

라고 쓰면 impact가 제대로 전달되지 않는다.

실제 문제는

Cross-Tenant Account Takeover
via Broken Authorization

에 가까울 수도 있다.


이런 게 Chain이다

화이트해킹 실무에서 재미있는 부분은 취약점 하나의 CVSS 숫자보다 취약점들이 어떻게 연결되는지 보는 것이다.

예를 들어 각각 따로 보면

Project IDOR
Member Enumeration
Invite Authorization Issue
Role Validation Issue

정도다.

그런데 연결하면

Cross-Tenant Object Access
          |
          v
Discover Tenant Identifier
          |
          v
Invite External Account
          |
          v
Assign Privileged Role
          |
          v
Tenant Compromise

가 된다.

이게 실제 penetration testing에서 꽤 중요한 사고방식이다.

Scanner는 보통 각각을 따로 본다.

사람은 관계를 본다.


그래서 Scanner보다 Burp Repeater를 오래 보게 된다

자동화는 당연히 필요하다.

하지만 Access Control 테스트는 아직도 사람이 직접 생각해야 하는 부분이 많다.

나는 이런 질문을 계속 던진다고 생각하면 된다.

Who am I?

What object am I accessing?

Who owns this object?

Which tenant owns it?

What role should be required?

Where is authorization enforced?

그리고 request 하나를 잡아서 identity만 바꾼다.

Request A + Token A
Request A + Token B

object만 바꾼다.

Token A + Object A
Token A + Object B

role을 바꾼다.

USER
MANAGER
ADMIN

이 과정을 반복하면 애플리케이션의 실제 권한 구조가 보이기 시작한다.


Introspection이 꺼져 있어도 끝난 게 아니다

GraphQL이라고 하면 많은 개발팀이 제일 먼저 하는 대응이 있다.

Disable Introspection

좋은 조치다.

Production에서 schema 노출을 줄일 수 있다.

하지만 introspection을 끄는 것과 authorization은 별개의 문제다.

PortSwigger 역시 GraphQL security에서 introspection 노출과 access-control 문제를 서로 다른 공격 표면으로 다룬다.

실제로 프론트엔드 JavaScript와 HTTP History만 봐도 상당수 query와 mutation을 알아낼 수 있다.

Browser
  |
  v
Frontend Application
  |
  v
GraphQL Requests
  |
  v
Burp HTTP History

그래서

Introspection Disabled

가

GraphQL Secure

를 의미하지 않는다.


개발팀과 얘기할 때 제일 많이 나오는 반응

이런 finding을 전달하면 종종 이런 답을 듣는다.

"근데 project ID는 UUID라서 다른 사람이 알 수 없는데요?"

여기서 pentester와 개발자의 관점이 갈린다.

UUID는 authorization mechanism이 아니다.

Hard to Guess
     !=
Authorized

ID가 랜덤이어도 다음 경로로 노출될 수 있다.

Logs
URLs
Emails
Exports
Browser History
API Responses
Third-party Integrations
Referrer Data
Other Vulnerabilities

그리고 보안은

공격자가 ID를 알아내기 어렵다.

가 아니라

ID를 알아도 접근할 수 없다.

가 되어야 한다.


수정도 resolver마다 if문 넣으면 끝이 아니다

급한 수정으로 이런 코드가 들어가기 쉽다.

if (project.ownerId !== user.id) {
  throw new ForbiddenError();
}

당장 취약점을 막는 데는 도움이 된다.

그런데 SaaS에서는 권한 모델이 보통 훨씬 복잡하다.

User
 |
 +-- Role
 |
 +-- Organization
 |
 +-- Workspace
 |
 +-- Project
 |
 +-- Resource

resolver 40개에 authorization 로직을 따로 넣으면 언젠가 하나 빠진다.

실제로 이번 문제도 그런 식으로 생겼다고 가정할 수 있다.

그래서 근본적인 remediation은 중앙화된 authorization layer 쪽이 낫다.

개념적으로는 이런 구조다.

Request
   |
   v
Authentication
   |
   v
Authorization Policy
   |
   +-- Tenant
   +-- Role
   +-- Ownership
   +-- Action
   |
   v
Resolver

핵심은

Default Allow

가 아니라

Default Deny

가 되는 것이다.


그리고 반드시 Retest한다

보안팀이 finding을 전달한다.

개발팀이 수정한다.

그리고 이런 답이 온다.

"수정 완료했습니다."

여기서 ticket을 바로 Close하면 안 된다.

똑같은 authorization matrix를 다시 돌린다.

                 Own    Same Tenant    Other Tenant

Read             PASS       PASS           PASS
Update           PASS       PASS           PASS
Delete           PASS       PASS           PASS
Invite           PASS       PASS           PASS
Change Role      PASS       PASS           PASS

그리고 기존 endpoint만 보는 것도 아니다.

개발자가 수정 과정에서 새 endpoint를 만든 경우도 있다.

Old Resolver
     |
     v
Patched

New Resolver
     |
     v
Authorization Missing

그래서 remediation diff도 가능하면 같이 본다.


결국 Senior Pentester가 보는 건 취약점 이름이 아니다

초반에는 이런 걸 많이 외운다.

XSS
SQLi
SSRF
XXE
IDOR
CSRF
SSTI
RCE

당연히 알아야 한다.

하지만 실무를 오래 할수록 질문이 조금 달라진다.

Where is the trust boundary?

Where is authorization enforced?

What does the server trust?

Can I cross tenant boundaries?

Can two low-severity issues become critical?

What is the minimum safe PoC?

What is the actual business impact?

이번 케이스도 시작은 별거 아니었다.

GraphQL request 하나.

updateProject(id)

그런데 한 단계씩 따라가다 보니

Object Authorization
       |
       v
Tenant Boundary
       |
       v
Invitation
       |
       v
Role Assignment
       |
       v
Privilege Escalation

까지 이어졌다.

이런 순간이 화이트해킹 실무에서 제일 재미있다.

엄청난 0-day를 발견해서가 아니다.

개발자가 각각 따로 만들어놓은 정상 기능 사이에서, 아무도 생각하지 않았던 경로 하나를 발견했기 때문이다.

Scanner 입장에서는 API request 몇 개다.

개발자 입장에서는 정상 기능 몇 개다.

하지만 공격자 입장에서는 하나의 attack path다.

그리고 모의해커가 하는 일은 결국 그 세 번째 시선으로 서비스를 보는 것이다.

“이 기능 자체는 정상인데, 내가 이 순서로 사용해도 정상일까?”

끝까지 읽었네… 메밀 승인 🐾
+