Step 198. GraphQL/API 보안 — 하나의 문으로 들어가 스키마를 통째로 읽다
Level 3 — CTF 실전과 공격 스킬 심화 | 난이도 ★★★☆☆ | 예상 소요 시간 3시간
전제: Step 197(JWT 심화)을 마쳤다. REST API와 JSON 응답을 읽을 수 있고, IDOR의 개념(Step 139 계열)을 안다.
- 준비물: 파이썬 3 + Flask(
python -m pip install flask), curl. - ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 합법 연습장 안내: 오늘 띄우는 Flask API 서버는 여러분 컴퓨터 안의 로컬 랩입니다. PortSwigger Web Security Academy의 GraphQL 랩과 취약 연습용 GraphQL 앱(DVGA 등)은 풀라고 만들어진 합법 플랫폼입니다. 이 두 곳 외에는 오늘의 기술을 쓰지 않습니다.
REST API가 "주소마다 정해진 데이터"를 준다면, GraphQL은 단 하나의 엔드포인트(/graphql)에서 클라이언트가 "이 필드, 저 필드만 골라 달라"고 쿼리를 씁니다. 개발에는 편한데, 보안에는 새로운 표면이 생깁니다. introspection이 켜져 있으면 스키마 전체 — 어떤 조회가 있고, 어떤 변경(mutation)이 있고, 각각 어떤 필드를 주고받는지 — 가 한 번의 요청으로 통째로 공개됩니다. 그리고 REST의 "주소별 권한" 감각이 그대로 이식되지 않아, 인증이 필드 단위로 빠진 곳이 많습니다.
오늘은 두 갈래로 갑니다. 전반부에서는 GraphQL의 요청 구조와 introspection, 숨겨진 mutation 찾기를 개념과 출력 예시로 익힙니다. 후반부에서는 Flask로 REST API를 직접 띄워, "필드 과다 노출"과 "인증 없는 삭제"가 얼마나 쉽게 생기는지 실측합니다 — GraphQL이든 REST든, API 보안의 뼈대는 같습니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- GraphQL 쿼리의 구조를 읽고 REST와의 차이를 설명한다
- introspection 쿼리가 왜 "스키마 전체 유출"인지 설명한다
- 스키마에서 숨겨진 mutation을 찾아 권한 없이 호출하는 공격 흐름을 안다
- Flask REST API에서 필드 과다 노출과 인증 없는 메서드를 재현한다
- REST/GraphQL 공통의 API 보안 체크리스트를 만든다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + Flask(REST 실측), GraphQL은 출력 예시 + PortSwigger Academy(워게임) |
| 오늘의 명령 | {"query": "..."}, { __schema { ... } }, curl -X DELETE, 개발자 도구 Network 탭 |
| 필요한 개념 | GraphQL 쿼리/뮤테이션, introspection, 필드 단위 인증, IDOR, 과다 노출 |
| 오늘의 산출물 | REST API 취약점 재현 서버 + GraphQL 공격 흐름 노트 + API 체크리스트 |
2-1. GraphQL — 하나의 문, 클라이언트가 고르는 데이터
REST에서 사용자 정보를 얻으려면 /api/v1/users/1 같은 주소를 두드리고, 서버가 정한 모양의 JSON을 받습니다. GraphQL은 다릅니다. 주소는 /graphql 하나고, 클라이언트가 원하는 필드를 쿼리로 적어 보냅니다.
{"query": "{ user(id: 1) { name email } }"}
서버는 name과 email만 돌려줍니다. 필요 없는 필드를 안 주니 효율적이고, 프론트엔드가 원하는 모양을 스스로 정합니다. 읽기는 query, 쓰기·변경은 mutation이라 부릅니다.
2-2. introspection — 스키마를 물어보는 쿼리
GraphQL 서버는 자기 자신을 설명하는 기능을 내장하고 있습니다. 이것이 introspection입니다.
{"query": "{ __schema { types { name fields { name } } } }"}
이 한 줄로 서버의 모든 타입과 필드 목록이 나옵니다. 개발 도구(자동완성, 문서 생성)를 위해 만든 기능인데, 공격자에게는 "공격 가능한 모든 명령의 목록"입니다. 개발 환경에서는 유용하지만 운영 환경에서 켜져 있으면 정찰 단계를 한 방에 끝내 줍니다.
2-3. 필드 단위 인증 — REST 감각이 안 통하는 이유
REST에서 권한 검사는 보통 "주소 단위"입니다. /admin은 관리자만. GraphQL에서는 모든 요청이 /graphql 하나로 들어오고, 진짜 작업은 쿼리 안의 필드가 결정합니다. user(id: 2)를 묻는 것과 deleteUser(id: 2)를 실행하는 것이 같은 문으로 들어옵니다. 그래서 인증을 "필드 단위"로 설계해야 하는데, 이 빠진 곳이 많습니다 — 결과는 IDOR의 현대판입니다. 로그인만 되어 있으면 남의 객체도, 관리자 전용 mutation도 호출되는 사고가 반복됩니다.
2-4. REST의 고질병 — 과다 노출과 메서드 방치
REST도 안전하지 않습니다. 화면에는 이름과 이메일만 필요한데, 개발자가 DB 객체를 통째로 JSON으로 반환하면 비밀번호 해시·주민번호 같은 필드까지 실려 나갑니다 (과다 노출). 또 개발자가 GET만 생각하고 만든 엔드포인트가 실은 DELETE도 받도록 되어 있으면, 문서에 없는 삭제가 가능해집니다 (메서드 방치). 이 둘은 오늘 3절에서 직접 재현합니다.
3. 따라 하기
3-1. GraphQL 쿼리 구조 익히기 (출력 예시)
GraphQL 서버 라이브러리 설치 없이, 요청과 응답의 모양을 먼저 익힙니다. 아래는 PortSwigger 랩에서 볼 수 있는 전형적인 교환의 출력 예시입니다.
요청:
{"query": "{ user(id: 1) { name email } }"}
응답 예시:
{"data": {"user": {"name": "gildong", "email": "gildong@example.com"}}}
읽는 법: 요청에 적은 필드만 응답에 옵니다. password를 추가로 적으면? 서버가 그 필드에 인증을 안 걸어 두었다면 비밀번호 해시가 그대로 옵니다. "요청 모양을 내가 정한다"는 것이 GraphQL의 힘이자 공격의 입구입니다.
3-2. introspection으로 스키마 통째로 받기 (출력 예시)
{"query": "{ __schema { types { name fields { name } } } }"}
응답 예시 (출력 예시 — 랩 환경에 따라 다름):
{"data": {"__schema": {"types": [
{"name": "User", "fields": [{"name": "id"}, {"name": "name"}, {"name": "email"}, {"name": "password"}]},
{"name": "Mutation", "fields": [{"name": "deleteUser"}, {"name": "createPost"}]}
]}}}
읽는 법: 두 가지 수확이 있습니다. 첫째, User 타입에 password 필드가 존재한다는 사실. 둘째, deleteUser라는 mutation이 존재한다는 사실. 화면 어디에도 없는 기능이 스키마에는 적혀 있습니다. introspection이 막혀 있으면 차선책은 필드명 추측입니다 — 개발자 도구 Network 탭에서 앱이 실제로 보내는 쿼리를 수집해, 필드명의 패턴(getUser → deleteUser?)을 변형해 봅니다.
3-3. 숨겨진 mutation 호출 (출력 예시)
스키마에서 찾은 mutation을 권한 없이 호출해 봅니다.
{"query": "mutation { deleteUser(id: 2) { success } }"}
응답 예시 (출력 예시):
{"data": {"deleteUser": {"success": true}}}
읽는 법: 성공했다면 필드 단위 인증이 없는 것입니다. 로그인 사용자면 누구나 남의 계정을 지우는 구조 — REST의 IDOR과 같은 병인데, "모든 요청이 같은 주소로 간다"는 점 때문에 발견이 늦어지곤 합니다. 방어는 각 필드/mutation의 리졸버(resolver, 실제 데이터를 꺼내는 함수) 안에서 권한을 검사하는 것뿐입니다.
3-4. REST로 재현 — 과다 노출과 메서드 방치 (실측)
이제 같은 병이 REST에서 어떻게 생기는지 직접 띄워 봅니다. step198_restapi.py를 작성합니다.
from flask import Flask, jsonify
app = Flask(__name__)
app.json.ensure_ascii = False
USERS = {
1: {"id": 1, "name": "gildong", "email": "gildong@example.com",
"pw_hash": "5f4dcc3b5aa765d61d8327deb882cf99", "ssn": "901010-1******", "role": "user"},
2: {"id": 2, "name": "admin", "email": "admin@example.com",
"pw_hash": "21232f297a57a5a743894a0e4a801fc3", "ssn": "800101-1******", "role": "admin"},
}
# 취약 1: 필드 과다 노출 — 화면엔 이름만 필요한데 DB 객체를 통째로 반환
@app.route("/api/v1/users/<int:uid>")
def get_user(uid):
user = USERS.get(uid)
if not user:
return jsonify({"error": "not found"}), 404
return jsonify(user) # pw_hash, ssn, role까지 전부 노출
# 취약 2: 메서드 검증 없음 — 개발자가 GET만 생각했지만 DELETE도 받는다
@app.route("/api/v1/users/<int:uid>/delete", methods=["GET", "POST", "DELETE"])
def delete_user(uid):
if uid in USERS:
del USERS[uid]
return jsonify({"deleted": uid})
return jsonify({"error": "not found"}), 404
if __name__ == "__main__":
app.run(port=5497)
서버를 켜고(python step198_restapi.py) 요청합니다.
curl "http://127.0.0.1:5497/api/v1/users/1" # 내 정보
curl "http://127.0.0.1:5497/api/v1/users/2" # 남의 정보 (IDOR)
curl -X DELETE "http://127.0.0.1:5497/api/v1/users/2/delete" # 삭제 시도
curl "http://127.0.0.1:5497/api/v1/users/2" # 삭제 확인
출력 (2026-09-09 실측):
[내 정보 조회]
{"email":"gildong@example.com","id":1,"name":"gildong","pw_hash":"5f4dcc3b5aa765d61d8327deb882cf99","role":"user","ssn":"901010-1******"}
[남의 정보(IDOR)]
{"email":"admin@example.com","id":2,"name":"admin","pw_hash":"21232f297a57a5a743894a0e4a801fc3","role":"admin","ssn":"800101-1******"}
[DELETE로 계정 삭제 시도]
{"deleted":2}
[삭제 후 재조회]
{"error":"not found"} HTTP 404
읽는 법: 세 가지 병이 한 번에 보입니다. (1) 이름만 필요한 화면인데 pw_hash와 ssn까지 왔습니다 — 과다 노출. (2) 로그인 없이 2번(admin)의 정보를 읽었습니다 — IDOR. (3) 문서에 없을 DELETE가 그대로 먹혀 계정이 지워졌습니다 — 메서드 방치. GraphQL의 필드 단위 인증 문제와 정확히 같은 뿌리입니다: 서버가 "요청한 자가 이 데이터/이 행위에 권한이 있는가"를 항목마다 검사하지 않은 것.
3-5. 방어 — 응답을 ‘필요한 필드’로 조립하기
같은 기능을 안전하게 고쳐 봅니다. 핵심은 두 줄입니다.
PUBLIC_FIELDS = ("id", "name", "email")
SESSION_USER = 1 # 로그인했다고 가정한 내 id
@app.route("/api/v1/users/<int:uid>")
def get_user(uid):
if uid != SESSION_USER: # 권한: 남의 정보는 거부
return jsonify({"error": "forbidden"}), 403
user = USERS.get(uid)
if not user:
return jsonify({"error": "not found"}), 404
return jsonify({k: user[k] for k in PUBLIC_FIELDS}) # 필요한 필드만 조립
왜: jsonify(user)로 객체를 통째로 내주는 습관이 과다 노출의 출발점입니다. 응답은 항상 "이 API가 약속한 필드"로 새로 조립하고, 권한 검사는 경로 진입 직후에 둡니다. GraphQL이라면 이 검사가 리졸버마다 들어가야 합니다.
3-6. PortSwigger GraphQL 랩 연결
Academy의 GraphQL 랩에서의 순서는 오늘 흐름 그대로입니다. (1) Network 탭에서 앱이 보내는 쿼리를 수집해 엔드포인트와 필드명을 파악, (2) introspection 쿼리를 보내 스키마 전체를 확인, (3) 스키마에서 찾은 숨겨진 필드·mutation을 Burp Repeater로 호출. introspection이 막혀 있으면 필드명 사전으로 추측합니다. REST 쪽 점검도 병행하세요 — 버전 경로(/v1/admin 같은 내부 경로가 /v2에는 없는지), 메서드 변조(GET만 문서화됐는데 PUT/DELETE도 받는지), 속성 과다 노출의 3종이 고전 체크리스트입니다.
4. 미션과 연습문제
미션 — API 취약점 재현에서 방어까지
- 3-4의 REST API를 띄우고, 과다 노출·IDOR·DELETE 삭제 세 장면을 캡처합니다
- 3-5의 방어 코드를 적용해, 남의 정보 요청이 403으로, 응답에
pw_hash가 빠지는 것을 확인합니다 - GraphQL introspection 쿼리를 종이에(또는 메모장에) 쓰고, 응답에서 공격자가 얻는 것 두 가지를 적습니다
- REST/GraphQL 공통 API 보안 체크리스트를 5개 이상 만듭니다 (예: 필드 단위 권한 검사, introspection 운영 차단, …)
- PortSwigger GraphQL 랩 1개를 해결하고 write-up을 씁니다
연습문제
문제 1. GraphQL이 REST에 비해 "인증 설계가 어려운" 구조적 이유를 엔드포인트 관점에서 설명해 보세요.
문제 2. introspection이 켜진 서버에서 공격자가 얻는 것은 무엇이며, 이것이 곧바로 침해는 아니지만 왜 위험한가요?
문제 3. 3-4 실측에서 jsonify(user) 한 줄이 어떤 두 가지 문제를 동시에 만들었나요?
문제 4. introspection이 막힌 GraphQL 서버를 정착하는 차선책 두 가지를 말해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
1~2번은 3절 실측 그대로입니다. 2번에서 방어 후 기대 결과는: GET /api/v1/users/2 → 403 {"error": "forbidden"}, GET /api/v1/users/1 → 응답에 id/name/email만 있고 pw_hash·ssn·role 부재. DELETE 엔드포인트는 인증과 관리자 권한 검사를 붙이거나, 아예 methods를 좁히는 방향으로 고칩니다.
3번: introspection 응답에서 공격자는 (1) 모든 타입과 필드의 목록(공격 대상 명세), (2) mutation 목록(쓰기·삭제 명령의 존재)을 얻습니다.
4번 체크리스트 예시:
| # | 점검 항목 |
|---|---|
| 1 | 모든 필드·리졸버에서 권한을 검사하는가 (경로/엔드포인트 단위에만 의존하지 않는가) |
| 2 | 응답이 필요한 필드만 조립되어 나가는가 (객체 통째 반환 금지) |
| 3 | 운영 환경에서 introspection이 꺼져 있는가 |
| 4 | 문서화되지 않은 HTTP 메서드를 엔드포인트가 받지 않는가 |
| 5 | 내부용 경로(/v1/admin 등)가 외부에서 닿지 않는가 |
5번 write-up에는 "수집한 정상 쿼리 / introspection 결과 요약 / 호출한 숨겨진 필드·mutation / 서버가 안 한 검사"를 적습니다.
연습문제 해답
문제 1 해답. REST는 주소별로 기능이 나뉘어 있어 "주소 단위 권한"이라는 단순한 감각이 통합니다. GraphQL은 모든 요청이 /graphql 하나로 들어오고 실제 작업은 쿼리 안의 필드가 결정하므로, 권한 검사를 필드·리졸버 단위로 내려야 합니다. 이 설계 변경을 하지 않으면 로그인 검사만으로 모든 필드가 열립니다.
문제 2 해답. 서버의 모든 타입·필드·mutation의 목록, 즉 공격 가능한 명령의 전체 명세를 얻습니다. 스키마 자체는 비밀 데이터가 아니지만, 화면에 드러나지 않은 관리 기능의 존재와 정확한 호출 방법을 알려 주므로 정찰 비용을 0에 가깝게 만듭니다. 이후 공격은 이 목록을 따라가면 되기 때문에 위험합니다.
문제 3 해답. 첫째, 응답에 화면이 필요로 하지 않는 민감 필드(pw_hash, ssn, role)까지 실려 나가는 과다 노출. 둘째, 호출자의 신원·소유권과 무관하게 어떤 uid든 그 내용을 주는 구조가 되어 IDOR과 결합할 통로를 열었습니다. "DB 객체를 통째로 반환"하는 관용구 하나가 두 병의 공통 뿌리입니다.
문제 4 해답. 첫째, 개발자 도구 Network 탭에서 프론트엔드가 실제로 보내는 쿼리를 수집해 필드명과 구조를 파악한 뒤 변형합니다. 둘째, 필드명 사전으로 추측합니다 — getUser가 보이면 deleteUser, users, admin 같은 유사 명명을 시도해 에러 메시지의 차이("필드가 없다" vs "권한이 없다")로 존재 여부를 판별합니다.
완료 기준 체크리스트
- [ ] GraphQL의 단일 엔드포인트와 쿼리 구조를 설명할 수 있다
- [ ] introspection 쿼리를 쓰고 응답에서 공격자의 수확을 말할 수 있다
- [ ] 필드 단위 인증(리졸버 권한 검사)의 필요성을 설명할 수 있다
- [ ] REST API에서 과다 노출·IDOR·메서드 방치를 로컬에서 재현했다
- [ ] 방어 후 403과 필드 축소 응답을 확인했다
- [ ] REST/GraphQL 공통 체크리스트 5개 이상을 만들었다
- [ ] 미션: 방어 구현 + PortSwigger GraphQL 랩 1개 해결
6. 흔한 실수와 해결
벽 1. curl: (7) Failed to connect to 127.0.0.1 port 5497
원인: Flask 서버가 켜져 있지 않습니다.
해결: 터미널에 python step198_restapi.py를 띄운 채로 두세요. 포트가 이미 사용 중이면 포트를 바꾸고 curl 주소도 함께 바꿉니다.
벽 2. DELETE를 보냈는데 405가 온다
증상 계열 메시지:
405 METHOD NOT ALLOWED
원인: 방어된 상태입니다 — Flask 라우트의 methods에 DELETE가 없으면 405로 거부합니다. 3-4의 취약 서버는 일부러 DELETE를 열어 둔 것입니다.
해결: 405를 봤다면 그 엔드포인트는 메서드 방치가 없다는 뜻입니다. 취약 재현을 원하면 methods=["GET","POST","DELETE"]를 확인하세요.
벽 3. 응답의 한글이 \uXXXX로 깨져 보인다
원인: Flask의 JSON 기본 응답이 ASCII 이스케이프를 씁니다.
해결: 3-4처럼 app.json.ensure_ascii = False를 넣으면 한글이 그대로 나옵니다. 보안과는 무관하지만 캡처 가독성이 좋아집니다.
벽 4. GraphQL 랩에서 introspection이 400/거부된다
원인: 운영 권장 설정대로 introspection이 꺼져 있는 것입니다. 랩이 막은 것이 아니라 방어가 적용된 것입니다.
해결: 5번 문항 해답의 차선책으로 전환하세요 — Network 탭의 실제 쿼리 수집과 필드명 추측. "막혀 있다"는 것도 유효한 정찰 결과입니다.
벽 5. 방어 코드를 넣었는데도 pw_hash가 응답에 나온다
원인: jsonify(user)처럼 객체를 통째로 반환하는 코드가 남아 있거나, 화이트리스트 조립이 아니라 "민감 필드만 제거"(user.pop("pw_hash")) 방식이라 다른 민감 필드가 새로 추가될 때 새는 구조일 수 있습니다.
해결: 3-5처럼 "허용 필드로 새로 조립"하는 화이트리스트 방식으로 바꾸세요. 제거 방식은 필드가 늘 때마다 다시 뚫립니다.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| GraphQL | 단일 엔드포인트에서 클라이언트가 필드를 골라 요청하는 쿼리 언어 |
| query / mutation | 읽기 / 쓰기·변경 명령 |
| introspection | 스키마 전체를 물어보는 내장 기능 — 켜져 있으면 명세 유출 |
| 리졸버 | 필드의 실제 데이터를 꺼내는 함수 — 권한 검사가 있어야 할 자리 |
| 필드 단위 인증 | 경로가 아니라 필드마다 권한을 검사하는 GraphQL식 설계 |
| 과다 노출 | 필요한 것보다 많은 필드를 응답에 싣는 결함 |
| 메서드 방치 | 문서에 없는 HTTP 메서드까지 받는 엔드포인트 |
| 숨은 정찰 | Network 탭 쿼리 수집 + 필드명 추측 — introspection 차단 시 차선책 |
오늘의 명령어·코드
| 명령·코드 | 하는 일 |
|---|---|
{"query": "{ user(id:1){ name email } }"} |
GraphQL 읽기 쿼리 |
{"query": "{ __schema { types { name fields { name } } } }"} |
introspection — 스키마 통째로 |
mutation { deleteUser(id:2){ success } } |
쓰기 mutation 호출 |
curl -X DELETE "http://127.0.0.1:5497/..." |
메서드 방치 점검 |
jsonify({k: user[k] for k in PUBLIC_FIELDS}) |
응답 필드 화이트리스트 조립 (방어) |
methods=["GET","POST","DELETE"] |
Flask 라우트의 메서드 허용 목록 |
명령어보다 중요한 감각
API를 보면 두 가지를 묻습니다. 첫째, "이 응답의 필드가 이 화면에 필요한 만큼만인가" — 더 많이 오면 그 초과분이 곧 유출입니다. 둘째, "이 요청을 남의 것에도 보낼 수 있는가" — id만 바꿔 성공하면 IDOR이고, GraphQL이면 필드만 바꿔 성공하면 필드 단위 인증 누락입니다. GraphQL이 새 기술처럼 보여도 병의 이름은 낯익은 것들입니다: 과다 노출, IDOR, 권한 검사 누락. 새로운 것은 문이 하나라는 것뿐이고, 그래서 검사가 문이 아니라 필드마다 있어야 합니다.
전부 체크되면 Step 198 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.