01 — Attack Surface of GraphQL
한 줄 답: GraphQL의 공격 면적은 endpoint가 아니라 쿼리 자체다. REST는 path마다 권한·rate limit·로깅을 끼울 수 있지만, GraphQL은 POST /graphql 하나에 전체 그래프를 탐색하는 쿼리 한 줄이 담겨 들어온다 — 그 한 줄이 4가지 정형 공격(introspection · alias · batching · cyclic)의 무기다.
Why — 왜 이 정의가 중요한가
GraphQL을 처음 production에 올리는 팀은 거의 항상 다음 셋 중 하나로 위험을 잘못 측정한다.
| 흔한 오해 | 실제 |
|---|---|
| ”endpoint가 하나니까 nginx로 막으면 끝” | nginx는 body를 모르고, 쿼리 한 줄의 비용도 모른다 — 100 req/min 안에서 한 요청이 DB를 죽인다 |
| ”introspection은 읽기니까 안전” | schema를 통째로 얻으면 공격자가 다음 어떤 필드를 칠지를 정확히 안다 — recon 단계가 자동화된다 |
| ”auth만 잘 끼우면 됨” | auth는 누가에 답하고, depth/complexity는 얼마나에 답한다 — 둘은 독립 축이다 |
이 셋을 분리하면 “왜 REST 보안 노하우가 그대로 안 통할까?”, “왜 GraphQL은 introspection이 보안 이슈일까?”, “왜 인증된 사용자가 더 위험할 수 있을까?”라는 질문이 동시에 풀린다.
How — 어떻게 공격되나
1) 면적의 위치 이동
REST에서 공격 면적 = endpoint 목록이다.
GET /users → 목록
GET /users/:id → 한 명
POST /users → 생성
POST /users/:id/posts → 글쓰기
...각 path마다 별도의 컨트롤러, 별도의 권한, 별도의 SQL이 있다. 그래서 path 단위로 인증·로깅·rate limit을 끼우는 것이 충분히 정밀하다.
GraphQL은 모든 요청이 POST /graphql로 간다. 어떤 데이터를 받을지는 path가 아니라 body 안의 query string이 정한다.
POST /graphql HTTP/1.1
Content-Type: application/json
{"query":"{ user(id:1) { posts { author { posts { ... } } } } }"}→ 즉 면적은 path 목록에서 쿼리가 도달할 수 있는 모든 필드의 곱집합으로 이동했다. nginx가 보던 path는 하나뿐이고, 진짜 면적은 body 안에 있다.
2) 4대 공격 패턴
OWASP GraphQL Cheat Sheet와 dolevf/Black-Hat-GraphQL 도구가 모은 공격은 거의 네 가지 정형으로 수렴한다.
(a) Introspection 공격
GraphQL spec은 모든 GraphQL 서버는 introspection을 지원해야 한다고 강제한다 — 하지만 production에 켜야 한다는 말은 없다. 디폴트는 켜짐이다.
query AttackerRecon {
__schema {
types {
name
fields {
name
type { name kind ofType { name } }
args { name type { name } }
}
}
}
}이 한 방으로 공격자는 전체 type · field · args · directive를 합법적으로 다운로드한다. 다음 공격은 추측이 아니라 지식 기반이 된다 — 어떤 mutation에 password 인자가 있는지, 어떤 type이 internalNotes 필드를 가지는지 모두 보인다.
(b) Alias 공격 (amplification)
GraphQL alias는 같은 필드를 다른 이름으로 N번 호출할 수 있게 한다.
mutation BruteForce {
a1: login(email: "u@e.com", password: "p1") { token }
a2: login(email: "u@e.com", password: "p2") { token }
a3: login(email: "u@e.com", password: "p3") { token }
# ... a1000 까지
}서버가 요청 단위 rate limit만 끼우면, 위는 1 request다. 하지만 resolver 호출은 1000번. 비밀번호 brute force·credential stuffing·OTP 우회의 증폭기가 된다.
→ 방어는 alias 개수 제한 (graphql-no-alias 등) + cost 기반 rate limit(05).
(c) Batching 공격
GraphQL은 spec에서 batching을 정의하지 않지만, 많은 구현(Apollo, Yoga)이 JSON 배열 batching을 지원한다.
POST /graphql
Content-Type: application/json
[
{"query":"mutation { login(email:\"u@e.com\", password:\"p1\") { token } }"},
{"query":"mutation { login(email:\"u@e.com\", password:\"p2\") { token } }"},
...
]→ 한 HTTP 요청 안에 수십 개의 operation. alias와 결합하면 1 HTTP × N alias × M batch = N·M의 증폭. 2018년 HackerOne의 GitLab GraphQL batching bypass가 정확히 이 패턴이었다.
→ 방어는 batching 비활성 (Apollo는 allowBatchedHttpRequests: false) 또는 batch 개수 상한.
(d) Cyclic 공격 (재귀 쿼리)
GraphQL의 type system은 상호 참조를 자연스럽게 허용한다.
type User { posts: [Post!]! }
type Post { author: User! }이 그래프에서 다음 쿼리가 문법적으로 합법이다.
{
user(id: 1) {
posts {
author {
posts {
author {
posts {
author {
posts { id } # 깊이 7
}
}
}
}
}
}
}
}DataLoader가 없다면 DB 라운드트립 7+ 깊이, 있어도 결과 크기가 지수 폭발. 메모리 OOM 또는 timeout이 정형이다.
→ 방어는 depth limit + complexity limit(02).
3) 5층 방어 — Defense in Depth
면적이 한 점에 모이는 만큼, 방어는 층을 나눠 둔다 — 각 층에서 조금씩 자르면 한 층이 뚫려도 나머지가 받는다.
What — 구체 사양
OWASP GraphQL Cheat Sheet의 12 권고 (요약)
OWASP의 공식 cheat sheet는 12개 권고를 3그룹으로 묶는다.
| 그룹 | 권고 | 이 챕터의 위치 |
|---|---|---|
| 입력 검증 | depth limit · amount limit · complexity · alias 제한 · directive overload 방지 · field duplication 제한 | 02 |
| 정보 노출 | introspection 비활성(production) · GraphiQL 비활성 · 에러 메시지 정제 · stack trace 제거 | 03 |
| 권한·rate | auth on every resolver · query cost analysis · timeout · CSRF 방지 | 04, 05 |
Black-Hat-GraphQL 도구 (오픈소스 공격 도구)
github.com/dolevf/Black-Hat-GraphQL 책의 부속 도구들은 상기 4대 패턴을 자동화한다. 대표 도구.
| 도구 | 하는 일 |
|---|---|
graphw00f | GraphQL 엔진 fingerprint (Apollo? Yoga? Hot Chocolate? — 알려진 CVE로 직결) |
clairvoyance | introspection이 꺼져 있을 때 field 추측 공격 (에러 메시지 fingerprint) |
graphql-cop | 7가지 안티패턴 자동 진단 (introspection on, alias 없음, batching 허용 등) |
crackql | GraphQL 위 password spray / OTP brute force |
→ 즉 공격은 수동이 아니라 자동화 도구가 이미 있다. depth/complexity/introspection 정책은 fingerprint 단계에서 결정된다.
4 패턴별 권장 디폴트 (Apollo Server 기준)
| 공격 | 권장 디폴트 |
|---|---|
| Introspection | NODE_ENV=production일 때 자동 비활성 (Apollo 4의 기본값) |
| Alias | graphql-no-alias로 alias 개수 ≤ 30 |
| Batching | allowBatchedHttpRequests: false 또는 batch 크기 ≤ 10 |
| Cyclic | graphql-depth-limit로 depth ≤ 10 (대부분의 정상 쿼리는 7 미만) |
What-if — 잘못 이해하면
1) “endpoint가 하나니까 단순하다”라고 보면
→ body 안의 쿼리가 새로운 공격 표면이라는 점을 놓친다. 대응: nginx 외에 GraphQL-aware 검사층(Apollo plugin, Yoga middleware)을 반드시 둔다.
2) Introspection을 “디버깅 도구”로만 보면
→ production에서 켠 채 배포한다. 공격자가 recon을 자동화한다.
대응: 03의 introspection 정책 — 끄기 vs persisted query로 대체.
3) Alias·batching을 편의 기능으로만 보면
→ 비밀번호 brute force가 1 HTTP req로 들어온다. 대응: alias 개수 제한 + batching 비활성 또는 cost 기반 rate limit.
4) Depth limit만 끼우고 안심하면
→ depth 3짜리 { users(first: 10000) { posts(first: 1000) { ... } } }가 복잡도 1000만으로 들어온다.
대응: 02의 complexity / cost 계산까지 함께.
5) “auth만 잘 끼우면 됨”이라고 믿으면
→ 인증된 사용자가 복잡도 100만 쿼리로 자기 자신의 서버를 죽인다.
대응: auth(04)와 cost(05)는 독립. 둘 다 끼운다.
Insight — 흥미로운 이야기
”GitHub은 4년간 무료로 운영했다”
GitHub은 2016년 GraphQL API v4를 처음 공개했을 때 rate limit이 없었다. 1년이 지나기 전에 한 botnet이 __schema 덤프를 분당 수천 회 호출하며 schema fingerprint DB를 만들기 시작했다. GitHub은 그 트래픽을 block하지 않고, 대신 cost-based rate limit을 발명했다 — 5000 point/h, 쿼리당 cost 공식 공개. 이 모델이 de facto 표준이 됐다 (05).
→ 교훈: GraphQL의 보안은 block이 아니라 cost 환산으로 푸는 게 더 자연스럽다.
”Shopify의 ‘API by query, not by endpoint’”
Shopify Storefront API는 모든 필드에 cost가 매겨져 있다 — products는 1 point, products(first: 250)은 250 point, metafields는 별도의 추가 cost. 조회 깊이가 아니라 작업 단위가 비용이라는 발상이다. 클라이언트는 자기 쿼리의 예상 cost를 미리 계산할 수 있고, 응답의 extensions.cost로 실제 cost를 검증한다.
→ 교훈: cost는 공격 방어만이 아니라 클라이언트와의 계약이기도 하다.
”Apollo가 4.0에서 introspection을 디폴트로 끈 이유”
2023년 Apollo Server 4.0은 NODE_ENV=production일 때 introspection을 자동으로 비활성화하는 break-change를 넣었다. 이전까지 수많은 production이 모르고 켠 채 운영됐기 때문이다. 출시 노트의 한 줄 — “We are tired of seeing leaked schemas on Shodan.”
→ 교훈: 디폴트의 위치가 보안의 위치다. spec이 강제하지 않는 영역은 프레임워크가 디폴트로 닫아두는 것이 사실상의 표준화 메커니즘.
요약 + 다이어그램
GraphQL의 공격 면적은 endpoint가 아니라 쿼리 자체다. 4대 공격 — introspection · alias · batching · cyclic — 은 모두 합법적인 쿼리다. 방어는 5층으로 나눠 둔다 — HTTP → Parser → Validator → AuthZ → Resolver. 다음 문서는 그중 Validator 층의 핵심 — depth & complexity limit이다.
다음 문서:
02-query-depth-and-complexity-limit.mdx— Validator 층에서 얼마나 깊은 쿼리, 얼마나 복잡한 쿼리까지 받을 것인가.