웹 보안 기본 3대 공격: SQL Injection, XSS, CSRF 원리와 방어법
로그인 폼에 ' OR '1'='1 한 줄을 넣으면 관리자로 로그인되고, 댓글 하나로 다른 사용자의 브라우저에서 스크립트가 실행된다. 웹 서비스가 가장 많이 당하는 SQL Injection, XSS, CSRF가 실제로 어떻게 동작하는지 직접 공격해보고, 각각을 막는 올바른 방법을 정리했다.
입력값을 믿는 순간 생기는 일
웹 보안 사고의 대부분은 한 가지 실수에서 시작한다. 사용자가 보낸 값을 코드나 명령의 일부로 그대로 써버리는 것이다.
| 공격 | 사용자 입력이 어디서 "코드"가 되나 | 피해 |
|---|---|---|
| SQL Injection | 서버의 SQL 쿼리 안에서 | 로그인 우회, 데이터 유출·삭제 |
| XSS | 다른 사용자의 브라우저 HTML 안에서 | 세션 탈취, 사용자 대신 행동 |
| CSRF | (입력이 아니라) 브라우저가 자동으로 보내는 쿠키를 악용 | 사용자 몰래 송금, 비밀번호 변경 |
이 세 가지는 OWASP Top 10 같은 웹 보안 위험 목록에 꾸준히 오르는 대표적인 공격이다. 하나씩 직접 공격해보고 막아보자.
1. SQL Injection: 입력이 쿼리가 될 때
공격해보기
로그인을 이렇게 구현했다고 해보자. 사용자가 입력한 이메일과 비밀번호를 문자열로 이어 붙여 SQL을 만든다.
function login(email, password) {
const sql = `SELECT * FROM users WHERE email = '${email}' AND password = '${password}'`;
return db.prepare(sql).get();
}
정상적인 입력에서는 문제가 없다. 그런데 이메일 칸에 admin@site.com' --를 넣으면 SQL이 이렇게 바뀐다.
SELECT * FROM users WHERE email = 'admin@site.com' --' AND password = 'anything'
--는 SQL 주석이다. 비밀번호 검사 부분이 통째로 주석이 되어, 비밀번호 없이 관리자로 로그인된다. 이메일조차 모를 때는 ' OR '1'='1' --를 넣으면 조건이 항상 참이 되어 첫 번째 사용자(대개 관리자)로 로그인된다.
실제로 SQLite에서 실행해보면 두 공격 모두 관리자 계정을 돌려준다.
공격 1: { id: 1, email: 'admin@site.com', role: 'admin' }
공격 2: { id: 1, email: 'admin@site.com', role: 'admin' }
검색 기능에 같은 실수가 있으면 더 심각하다. UNION으로 다른 테이블의 데이터를 꺼낼 수 있다.
검색어: zzz' UNION SELECT email || ':' || password FROM users --
결과: admin@site.com:s3cret!, kim@site.com:hunter2 ← 모든 계정의 비밀번호
방어: 파라미터 바인딩
SQL Injection을 막는 방법은 입력값을 SQL 문자열에 이어 붙이지 않는 것 하나다.
값이 들어갈 자리에 ?(플레이스홀더)를 두고, 값은 따로 넘긴다. 이렇게 하면 데이터베이스가 SQL 구조를 먼저 해석하고 값은 항상 "값"으로만 취급한다. '나 --가 들어와도 그냥 문자일 뿐이다.
function login(email, password) {
return db
.prepare("SELECT * FROM users WHERE email = ? AND password = ?")
.get(email, password);
}
공격 1: undefined ← 로그인 실패
공격 2: undefined ← 로그인 실패
정상 로그인: { id: 2, email: 'kim@site.com', role: 'user' }
| 도구 | 안전한 방법 |
|---|---|
| Node.js DB 드라이버 | query("... WHERE id = ?", [id]), $1 같은 플레이스홀더 |
| ORM (Prisma, Sequelize 등) | 기본 API는 안전. raw query에 문자열을 이어 붙일 때 주의 |
| 테이블명, 정렬 컬럼처럼 플레이스홀더를 못 쓰는 곳 | 허용 목록(["name", "date"])에 있는 값만 사용 |
위 예시처럼 비밀번호를 평문으로 저장하는 것 자체도 큰 문제다. SQL Injection이 아니어도 DB가 유출되는 순간 모든 비밀번호가 노출된다. 비밀번호는 bcrypt, argon2 같은 전용 해시 함수로 저장하자.
2. XSS: 입력이 다른 사람의 브라우저에서 실행될 때
XSS(Cross-Site Scripting)는 공격자가 넣은 내용이 다른 사용자의 페이지에서 HTML로 해석되어 스크립트가 실행되는 공격이다. 댓글, 닉네임, 검색어처럼 사용자 입력을 화면에 다시 보여주는 모든 곳이 대상이다.
아래 미리보기에서 직접 해보자. 같은 댓글을 innerHTML과 textContent로 각각 출력한다.
"innerHTML로 출력"을 누르면 댓글 안의 <img> 태그가 진짜 이미지 태그로 해석된다. 이미지 로딩이 실패하면서 onerror 속성에 들어 있던 코드가 댓글을 본 사람의 브라우저에서 실행된다. 실제 공격이라면 이 자리에서 쿠키를 공격자 서버로 보내거나, 사용자 대신 글을 쓰고 비밀번호를 바꾸는 요청을 보낼 것이다.
"textContent로 출력"은 같은 입력을 글자 그대로 보여준다. <img ...>가 화면에 문자로 찍힐 뿐 아무것도 실행되지 않는다.
<script>alert(1)</script>를 innerHTML에 넣으면 실행되지 않는다. 그래서 "막혔다"고 착각하기 쉽지만, onerror, onload 같은 이벤트 속성은 그대로 실행된다. 특정 태그를 걸러내는 방식으로는 XSS를 막을 수 없는 이유다.
XSS의 종류
| 종류 | 악성 입력이 오는 곳 | 예시 |
|---|---|---|
| 저장형 (Stored) | DB에 저장됐다가 여러 사람에게 보여짐 | 게시글, 댓글, 프로필 |
| 반사형 (Reflected) | URL 파라미터가 그대로 응답에 포함됨 | ?q=<img onerror=...> 링크를 클릭하게 유도 |
| DOM 기반 | 서버를 거치지 않고 프론트 코드가 직접 삽입 | location.hash를 innerHTML에 넣음 |
방어
- 출력할 때 이스케이프한다. <, >, ", ', &를 HTML 엔티티로 바꿔서 글자로만 보이게 한다. 텍스트는 textContent로 넣는다.
- 프레임워크의 기본 출력을 쓴다. Vue의 {{ }}, React의 { }는 자동으로 이스케이프한다. 위험한 건 우회 통로인 Vue의 v-html, React의 dangerouslySetInnerHTML이다.
- HTML을 꼭 허용해야 하면 정제한다. 게시판 에디터처럼 서식을 허용해야 한다면 DOMPurify 같은 검증된 라이브러리로 허용할 태그만 남긴다.
- 피해를 줄이는 장치를 더한다. 세션 쿠키에 HttpOnly를 붙이면 스크립트가 쿠키를 읽지 못한다. CSP(Content-Security-Policy) 헤더로 허용되지 않은 스크립트의 실행을 막을 수 있다.
// ❌ 사용자 입력을 HTML로
el.innerHTML = comment;
// ✅ 글자로
el.textContent = comment;
3. CSRF: 브라우저가 자동으로 보내는 쿠키를 악용
CSRF(Cross-Site Request Forgery)는 앞의 두 공격과 성격이 다르다. 코드를 주입하지 않는다. 대신 브라우저가 요청마다 쿠키를 자동으로 붙여 보낸다는 점을 이용한다.
사용자가 은행 사이트(bank.com)에 로그인한 상태로, 공격자가 만든 페이지에 들어갔다고 하자. 그 페이지에는 이런 폼이 숨어 있다.
<!-- evil.com의 페이지 -->
<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>
페이지를 여는 순간 bank.com으로 송금 요청이 간다. 브라우저는 이 요청에 bank.com의 로그인 쿠키를 붙여서 보내고, 은행 서버 입장에서는 로그인한 사용자가 직접 보낸 정상 요청과 구분할 수 없다.
CORS 글에서 봤듯이, 폼 전송 같은 단순 요청은 CORS에 막히지 않고 서버에 도착한다. CORS는 응답을 읽지 못하게 할 뿐이라서, 송금처럼 요청이 도착하는 것 자체가 피해인 경우는 막아주지 못한다.
방어
| 방법 | 원리 |
|---|---|
| 쿠키에 SameSite=Lax 또는 Strict | 다른 사이트에서 시작된 요청에는 쿠키를 붙이지 않는다. 대부분의 CSRF를 막는다 |
| CSRF 토큰 | 서버가 폼마다 예측할 수 없는 토큰을 심고, 요청에 그 토큰이 있는지 확인한다. 공격자 페이지는 토큰을 알 수 없다 |
| Origin 헤더 확인 | 상태를 바꾸는 요청의 Origin이 내 사이트인지 확인한다 |
| 상태 변경은 GET으로 하지 않기 | GET /delete?id=3 같은 주소는 <img src> 하나로도 요청된다 |
요즘 브라우저는 SameSite 속성이 없는 쿠키를 Lax처럼 다루기도 하지만, 브라우저마다 동작이 같지는 않다. 인증 쿠키에는 SameSite를 직접 명시하는 것이 안전하다. 쿠키 속성은 JWT vs 세션 글에 더 자세히 정리해 두었다.
정리: 왜 알아두면 좋은가
| 공격 | 핵심 원인 | 핵심 방어 |
|---|---|---|
| SQL Injection | 입력을 SQL 문자열에 이어 붙임 | 파라미터 바인딩 (? 플레이스홀더) |
| XSS | 입력을 HTML로 해석해서 출력 | 출력 이스케이프 (textContent, 프레임워크 기본 출력), v-html 등 주의, CSP |
| CSRF | 브라우저가 쿠키를 자동으로 보냄 | SameSite 쿠키, CSRF 토큰, Origin 확인 |
세 공격의 공통점은 "데이터"와 "코드"의 경계가 무너질 때 생긴다는 것이다. SQL에서는 값이 쿼리 구조가 되고, HTML에서는 글자가 태그가 되고, CSRF에서는 공격자의 요청이 사용자의 요청이 된다. 방어도 같은 원칙이다. 사용자 입력은 끝까지 데이터로만 다루고, 요청이 정말 사용자의 의도인지 확인하자.